From c2e149aa1db6d84aa08406701aed64863cbf2433 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Wed, 6 Oct 2021 17:03:02 +0200 Subject: [PATCH 01/10] use config as arg for function instead of values --- src/kassandra/__main__.py | 8 ++------ src/kassandra/bot.py | 16 +++++++--------- src/kassandra/webhook.py | 6 +++--- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/kassandra/__main__.py b/src/kassandra/__main__.py index 90a4d15..0b3f6a0 100644 --- a/src/kassandra/__main__.py +++ b/src/kassandra/__main__.py @@ -17,10 +17,7 @@ async def __main(): bot_corout = send_messages( message_queue, - config.username, - config.homeserver, - config.password, - config.alert_rooms + config ) format_corout = format_alerts( alert_queue, @@ -28,8 +25,7 @@ async def __main(): ) webhook_corout = run_webhook( alert_queue, - config.host, - config.port + config ) diff --git a/src/kassandra/bot.py b/src/kassandra/bot.py index 5f5c723..a58dcf7 100644 --- a/src/kassandra/bot.py +++ b/src/kassandra/bot.py @@ -9,6 +9,7 @@ from typing import ( ) from matrix_bot.client import Client from matrix_bot.invite_policy import WhiteList +from .config import Config from .format import Message async def __send_messsages( @@ -31,27 +32,24 @@ async def __send_messsages( async def send_messages( message_queue: asyncio.Queue[dict[str, Any]], # For now, type will change in the futur - username: str, - homeserver: str, - password: str, - alert_rooms: list[str] + config: Config ): """ Initialize the bot and send messages added to the queue to the alert_rooms. """ bot = await Client( - username, - homeserver, - password + config.username, + config.homeserver, + config.password ) - invite_policy = await WhiteList(bot, alert_rooms) + invite_policy = await WhiteList(bot, config.alert_rooms) bot.set_invite_policy(invite_policy) await asyncio.gather( bot.run(), __send_messsages( message_queue, bot, - alert_rooms + config.alert_rooms ) ) diff --git a/src/kassandra/webhook.py b/src/kassandra/webhook.py index 709959c..7a91237 100644 --- a/src/kassandra/webhook.py +++ b/src/kassandra/webhook.py @@ -10,13 +10,13 @@ from typing import ( Any, NoReturn ) +from .config import Config ENDPOINT = "/webhook" async def run_webhook( alert_queue: asyncio.Queue[dict[str, Any]], - host: str, - port: int + config: Config )->NoReturn: """ Run the webhook endpoint and put the alerts in the queue. @@ -31,5 +31,5 @@ async def run_webhook( app.add_routes([aiohttp.web.post(ENDPOINT, handler)]) runner = aiohttp.web.AppRunner(app) await runner.setup() - site = aiohttp.web.TCPSite(runner, host, port) + site = aiohttp.web.TCPSite(runner, config.host, config.port) await site.start() From c339c505add68f33f5d3622c0e4d1883390cc4c0 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Wed, 6 Oct 2021 17:27:37 +0200 Subject: [PATCH 02/10] add ssl entries to Config --- src/kassandra/config.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/kassandra/config.py b/src/kassandra/config.py index d695be9..c0e5d78 100644 --- a/src/kassandra/config.py +++ b/src/kassandra/config.py @@ -5,14 +5,37 @@ Config related tools. import dataclasses import yaml +from typing import Optional + @dataclasses.dataclass class Config: username: str homeserver: str password: str - port: int - host: str alert_rooms: list[str] + port: int = 8000 + host: str = "127.0.0.1" + tls: bool = False + tls_auth: bool = False + tls_crt: Optional[str] = None + tls_key: Optional[str] = None + ca_crt: Optional[str] = None + + def check_integrity(self)->bool: + """ Check the integrity of the config. + Raise an error if the config is invalid, + should always return True (or raise an error). + """ + if self.tls_auth and not self.tls: + raise ValueError("tls_auth is enable, but not tls.") + if self.tls and tls_crt is None: + raise ValueError("tls is enable but tls_crt was not provided") + if self.tls and tls_key is None: + raise ValueError("tls is enable but tls_key was not provided") + if self.tls_auth and ca_cert is None: + raise ValueError("tls_auth is enable, but ca_crt was not provided") + return True + def load_config(file:str)->Config: """ @@ -20,5 +43,7 @@ def load_config(file:str)->Config: """ with open(file, 'r') as f: data = yaml.load(f, Loader=yaml.loader.SafeLoader) - return Config(**data) + config = Config(**data) + config.check_integrity() + return config From cb55318b91ffc5936982cdbedb9d5ae3a5609810 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Thu, 7 Oct 2021 17:12:06 +0200 Subject: [PATCH 03/10] add endpoint to config --- src/kassandra/config.py | 1 + src/kassandra/webhook.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/kassandra/config.py b/src/kassandra/config.py index c0e5d78..0d4ff91 100644 --- a/src/kassandra/config.py +++ b/src/kassandra/config.py @@ -15,6 +15,7 @@ class Config: alert_rooms: list[str] port: int = 8000 host: str = "127.0.0.1" + endpoint: str = "/webhook" tls: bool = False tls_auth: bool = False tls_crt: Optional[str] = None diff --git a/src/kassandra/webhook.py b/src/kassandra/webhook.py index 7a91237..efa71ac 100644 --- a/src/kassandra/webhook.py +++ b/src/kassandra/webhook.py @@ -12,8 +12,6 @@ from typing import ( ) from .config import Config -ENDPOINT = "/webhook" - async def run_webhook( alert_queue: asyncio.Queue[dict[str, Any]], config: Config @@ -28,7 +26,7 @@ async def run_webhook( return aiohttp.web.Response() app = aiohttp.web.Application() - app.add_routes([aiohttp.web.post(ENDPOINT, handler)]) + app.add_routes([aiohttp.web.post(config.endpoint, handler)]) runner = aiohttp.web.AppRunner(app) await runner.setup() site = aiohttp.web.TCPSite(runner, config.host, config.port) From d89c0a2626632b9fb8da8931ce7f5b46663a6ccf Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Thu, 7 Oct 2021 17:18:55 +0200 Subject: [PATCH 04/10] setup the structure to use ssl --- src/kassandra/webhook.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/kassandra/webhook.py b/src/kassandra/webhook.py index efa71ac..d0c70c4 100644 --- a/src/kassandra/webhook.py +++ b/src/kassandra/webhook.py @@ -5,6 +5,7 @@ The webhook receiving the alerts from alertmanager. import asyncio import aiohttp.web import aiohttp.web_request +import ssl from typing import ( Any, @@ -12,6 +13,9 @@ from typing import ( ) from .config import Config +def load_ssl_context(config:Config)->ssl.SSLContext: + pass + async def run_webhook( alert_queue: asyncio.Queue[dict[str, Any]], config: Config @@ -29,5 +33,8 @@ async def run_webhook( app.add_routes([aiohttp.web.post(config.endpoint, handler)]) runner = aiohttp.web.AppRunner(app) await runner.setup() - site = aiohttp.web.TCPSite(runner, config.host, config.port) + ssl_context = None + if config.tls: + ssl_context = load_ssl_context(config) + site = aiohttp.web.TCPSite(runner, config.host, config.port, ssl_context=ssl_context) await site.start() From f8258117f4bb15020355bf67d119a17e2dd3a823 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Thu, 7 Oct 2021 17:24:13 +0200 Subject: [PATCH 05/10] add health endpoint --- src/kassandra/webhook.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/kassandra/webhook.py b/src/kassandra/webhook.py index d0c70c4..1a6ef09 100644 --- a/src/kassandra/webhook.py +++ b/src/kassandra/webhook.py @@ -24,13 +24,19 @@ async def run_webhook( Run the webhook endpoint and put the alerts in the queue. """ - async def handler(request:aiohttp.web_request.Request): + async def handler(request:aiohttp.web_request.Request)->aiohttp.web.Response: alert = await request.json() await alert_queue.put(alert) return aiohttp.web.Response() + async def health(request:aiohttp.web_request.Request)->aiohttp.web.Response: + return aiohttp.web.Response(text="OK") + app = aiohttp.web.Application() - app.add_routes([aiohttp.web.post(config.endpoint, handler)]) + app.add_routes([ + aiohttp.web.post(config.endpoint, handler), + aiohttp.web.get("/health", health) + ]) runner = aiohttp.web.AppRunner(app) await runner.setup() ssl_context = None From 42760490b98c877a0d75e61904def1b33e4e76d9 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Thu, 7 Oct 2021 19:18:07 +0200 Subject: [PATCH 06/10] add SSL and mSSL support --- .gitignore | 2 ++ src/kassandra/config.py | 6 +++--- src/kassandra/webhook.py | 16 +++++++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a61e346..1fd0f17 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,5 @@ cython_debug/ # Project specific: .sync_token config.yaml +*.key +*.crt diff --git a/src/kassandra/config.py b/src/kassandra/config.py index 0d4ff91..75825e3 100644 --- a/src/kassandra/config.py +++ b/src/kassandra/config.py @@ -29,11 +29,11 @@ class Config: """ if self.tls_auth and not self.tls: raise ValueError("tls_auth is enable, but not tls.") - if self.tls and tls_crt is None: + if self.tls and self.tls_crt is None: raise ValueError("tls is enable but tls_crt was not provided") - if self.tls and tls_key is None: + if self.tls and self.tls_key is None: raise ValueError("tls is enable but tls_key was not provided") - if self.tls_auth and ca_cert is None: + if self.tls_auth and self.ca_crt is None: raise ValueError("tls_auth is enable, but ca_crt was not provided") return True diff --git a/src/kassandra/webhook.py b/src/kassandra/webhook.py index 1a6ef09..50b60a1 100644 --- a/src/kassandra/webhook.py +++ b/src/kassandra/webhook.py @@ -14,7 +14,21 @@ from typing import ( from .config import Config def load_ssl_context(config:Config)->ssl.SSLContext: - pass + """ + Load the SSL context from the config. + """ + ca_path = None + if config.tls_auth: + ca_path = config.ca_crt + ssl_context = ssl.create_default_context( + purpose=ssl.Purpose.CLIENT_AUTH, + cafile=ca_path + ) + if config.tls_auth: + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context.load_cert_chain(config.tls_crt, config.tls_key) + return ssl_context + async def run_webhook( alert_queue: asyncio.Queue[dict[str, Any]], From d2f202c72c59b8342ccc2c44e7809bf9bd33ce4f Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Fri, 8 Oct 2021 15:17:45 +0200 Subject: [PATCH 07/10] add formating --- setup.cfg | 1 + src/kassandra/format.py | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/setup.cfg b/setup.cfg index 583e008..88de4c7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,7 @@ packages = kassandra python_requires = >=3.9.2 package_dir = =src install_requires = + Jinja2>=3.0.2 PyYAML>=5.4.1 matrix-bot @ git+https://gitea.auro.re/histausse/matrix-bot.git diff --git a/src/kassandra/format.py b/src/kassandra/format.py index f298565..bef1adc 100644 --- a/src/kassandra/format.py +++ b/src/kassandra/format.py @@ -10,6 +10,25 @@ from typing import ( NoReturn, Optional ) +from jinja2 import Environment, BaseLoader + +template_raw = ( + "{% if alert['labels']['severity'] == 'critical' and alert['status'] == 'firing' %}" + "@room" + "{% elif alert['labels']['severity'] == 'warning' and alert['status'] == 'firing' %}" + "" + "{% elif alert['status'] == 'resolved' %}" + " End of alert " + "{% else %}" + "" + "{% endif %}" + "{{ alert['labels']['alertname'] }}: {{ alert['labels']['instance'] }}
" + "
" + "{{ alert['annotations']['title'] }}
" + "{{ alert['annotations']['description'] }}
" +) +template = Environment(loader=BaseLoader).from_string(template_raw) + @dataclasses.dataclass class Message: @@ -17,14 +36,20 @@ class Message: formated_body: Optional[str] def format_alert( - alert: dict[str, Any] -)->Message: + alert_json: dict[str, Any] +)->list[Message]: """ Format an alert in json format to a nice string. """ - body = json.dumps(alert, indent=4) - formated_body = f"
{body}
\n" - return Message(body, formated_body) + messages = [] + for alert in alert_json['alerts']: + formated_body = template.render(alert=alert, status=alert_json['status']) + body = f"{ alert['annotations']['title'] }:\n{ alert['annotations']['description'] }" + if '@room' in formated_body: + body = '@room ' + body + formated_body = formated_body.replace('@room', '') + messages.append(Message(body, formated_body)) + return messages async def format_alerts( alert_queue: asyncio.Queue[dict[str,Any]], @@ -35,7 +60,8 @@ async def format_alerts( """ while True: alert = await alert_queue.get() - message = format_alert(alert) - await message_queue.put(message) + messages = format_alert(alert) + for message in messages: + await message_queue.put(message) alert_queue.task_done() From 2352626598f9032eec2d001cc64240790d7cd59f Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Fri, 8 Oct 2021 15:46:49 +0200 Subject: [PATCH 08/10] add jinja templating --- src/kassandra/__main__.py | 3 ++- src/kassandra/config.py | 2 ++ src/kassandra/format.py | 57 ++++++++++++++++++++++++++++++++------- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/kassandra/__main__.py b/src/kassandra/__main__.py index 0b3f6a0..ce90465 100644 --- a/src/kassandra/__main__.py +++ b/src/kassandra/__main__.py @@ -21,7 +21,8 @@ async def __main(): ) format_corout = format_alerts( alert_queue, - message_queue + message_queue, + config ) webhook_corout = run_webhook( alert_queue, diff --git a/src/kassandra/config.py b/src/kassandra/config.py index 75825e3..99d9e74 100644 --- a/src/kassandra/config.py +++ b/src/kassandra/config.py @@ -21,6 +21,8 @@ class Config: tls_crt: Optional[str] = None tls_key: Optional[str] = None ca_crt: Optional[str] = None + default_template: Optional[str] = None + templates: dict[str, str] = dataclasses.field(default_factory=dict) def check_integrity(self)->bool: """ Check the integrity of the config. diff --git a/src/kassandra/format.py b/src/kassandra/format.py index bef1adc..6305110 100644 --- a/src/kassandra/format.py +++ b/src/kassandra/format.py @@ -5,14 +5,16 @@ Format the alert message. import asyncio import dataclasses import json +from collections import defaultdict from typing import ( Any, NoReturn, Optional ) -from jinja2 import Environment, BaseLoader +from jinja2 import BaseLoader, Environment, Template +from .config import Config -template_raw = ( +default_template_raw = ( "{% if alert['labels']['severity'] == 'critical' and alert['status'] == 'firing' %}" "@room" "{% elif alert['labels']['severity'] == 'warning' and alert['status'] == 'firing' %}" @@ -27,24 +29,58 @@ template_raw = ( "{{ alert['annotations']['title'] }}
" "{{ alert['annotations']['description'] }}
" ) -template = Environment(loader=BaseLoader).from_string(template_raw) - @dataclasses.dataclass class Message: body: str formated_body: Optional[str] +def load_templates( + config: Config +)->defaultdict[str, Template]: + """ + Create a dict mapping alert names to the template to use from + the config, with a default template either from the config file + or the default default_template_raw. + """ + if config.default_template is None: + default_template = Environment( + loader=BaseLoader + ).from_string( + default_template_raw + ) + else: + default_template = Environment( + loader=BaseLoader + ).from_string( + config.default_template + ) + templates = defaultdict(lambda: default_template) + for alert_name in config.templates: + templates[alert_name] = Environment( + loader=BaseLoader + ).from_string( + config.templates[alert_name] + ) + return templates + + def format_alert( - alert_json: dict[str, Any] + alert_json: dict[str, Any], + templates: defaultdict[str, Template] )->list[Message]: """ Format an alert in json format to a nice string. """ messages = [] for alert in alert_json['alerts']: - formated_body = template.render(alert=alert, status=alert_json['status']) - body = f"{ alert['annotations']['title'] }:\n{ alert['annotations']['description'] }" + template = templates[alert['labels']['alertname']] + formated_body = template.render(alert=alert) + body = "alert {status}:\n{alertname} on {instance}".format( + status=alert['status'], + alertname=alert['labels']['alertname'], + instance=alert['labels']['instance'] + ) if '@room' in formated_body: body = '@room ' + body formated_body = formated_body.replace('@room', '') @@ -53,14 +89,17 @@ def format_alert( async def format_alerts( alert_queue: asyncio.Queue[dict[str,Any]], - message_queue: asyncio.Queue[Message] + message_queue: asyncio.Queue[Message], + config: Config )->NoReturn: """ Read alerts from alert_queue, format them, and put them in message_queue. """ + templates = load_templates(config) + while True: alert = await alert_queue.get() - messages = format_alert(alert) + messages = format_alert(alert, templates) for message in messages: await message_queue.put(message) alert_queue.task_done() From 8165f2b1c04753f930858b43e16e1679e412d8ed Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Fri, 8 Oct 2021 15:54:58 +0200 Subject: [PATCH 09/10] some tests and reformating --- config.yaml.exp | 18 ++++++++++++++++++ src/kassandra/format.py | 16 ++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/config.yaml.exp b/config.yaml.exp index f91e69d..05209bd 100644 --- a/config.yaml.exp +++ b/config.yaml.exp @@ -7,4 +7,22 @@ host: 127.0.0.1 alert_rooms: - "#troy:matrix.org" - "#ithaca:matrix.org" +templates: + "Test": | + {% if alert['labels']['severity'] == 'critical' and alert['status'] == 'firing' %} + @room + {% elif alert['labels']['severity'] == 'warning' and alert['status'] == 'firing' %} + + {% elif alert['status'] == 'resolved' %} + End of alert + {% else %} + + {% endif %} + {{ alert['labels']['alertname'] }}: {{ alert['labels']['instance'] }}
+
+ {{ alert['annotations']['title'] }}
+ {{ alert['annotations']['description'] }}
+ + But this is a custom template for Cassandre, so, here is a warning:
+
Beware of greeks bearing gifts
... diff --git a/src/kassandra/format.py b/src/kassandra/format.py index 6305110..7a65d65 100644 --- a/src/kassandra/format.py +++ b/src/kassandra/format.py @@ -14,7 +14,12 @@ from typing import ( from jinja2 import BaseLoader, Environment, Template from .config import Config -default_template_raw = ( +@dataclasses.dataclass +class Message: + body: str + formated_body: Optional[str] + +DEFAULT_TEMPLATE_RAW: str = ( "{% if alert['labels']['severity'] == 'critical' and alert['status'] == 'firing' %}" "@room" "{% elif alert['labels']['severity'] == 'warning' and alert['status'] == 'firing' %}" @@ -30,24 +35,19 @@ default_template_raw = ( "{{ alert['annotations']['description'] }}
" ) -@dataclasses.dataclass -class Message: - body: str - formated_body: Optional[str] - def load_templates( config: Config )->defaultdict[str, Template]: """ Create a dict mapping alert names to the template to use from the config, with a default template either from the config file - or the default default_template_raw. + or the default DEFAULT_TEMPLATE_RAW. """ if config.default_template is None: default_template = Environment( loader=BaseLoader ).from_string( - default_template_raw + DEFAULT_TEMPLATE_RAW ) else: default_template = Environment( From 0de8d9f03637106ed6002bcfb60ab2b203c643a3 Mon Sep 17 00:00:00 2001 From: Jean-Marie Mineau Date: Fri, 8 Oct 2021 16:53:50 +0200 Subject: [PATCH 10/10] add pong callback --- src/kassandra/bot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/kassandra/bot.py b/src/kassandra/bot.py index a58dcf7..66983c8 100644 --- a/src/kassandra/bot.py +++ b/src/kassandra/bot.py @@ -7,11 +7,25 @@ from typing import ( Any, NoReturn ) +import nio from matrix_bot.client import Client from matrix_bot.invite_policy import WhiteList +from matrix_bot.utils import ignore_client_message from .config import Config from .format import Message +@ignore_client_message +async def __pong( + client: Client, + room: nio.rooms.MatrixRoom, + message: nio.events.room_events.RoomMessageText +): + """ + If the bot is pinged, send "Pong" + """ + if (client.user_name + ':') in message.body: + await client.send_message(room.room_id, "Pong") + async def __send_messsages( message_queue: asyncio.Queue[Message], bot: Client, @@ -45,6 +59,7 @@ async def send_messages( ) invite_policy = await WhiteList(bot, config.alert_rooms) bot.set_invite_policy(invite_policy) + bot.add_message_callback(__pong) await asyncio.gather( bot.run(), __send_messsages(