diff --git a/Dockerfile b/Dockerfile index 95bfd1c..17cce14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,8 @@ ADD requirements.txt . RUN python -m pip install -r requirements.txt WORKDIR /app -ADD src/alertify.py /app -ADD src/gotify.py /app +COPY alertify.py /app +COPY src /app/src # Switching to a non-root user, please refer to https://aka.ms/vscode-docker-python-user-rights RUN useradd appuser && chown -R appuser /app diff --git a/README.md b/README.md index ae616bf..7273187 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ The following environment variables will override any config or default: * Listens on port 8080 by default. * Forwards `resolved` alerts, if not disabled. * Resolved alerts delete the original alert, if enabled. +* Requires a Gotify app key to send alerts to Gotify +* Requires a Gotify client key to delete original alert on resolution * Defaults, if not sent: | Field | Default value | |-------------|---------------| @@ -72,6 +74,7 @@ services: - "8080:8080" environment: - TZ=Europe/London + - DELETE_ONRESOLVE=true - GOTIFY_KEY=_APPKEY_ - GOTIFY_CLIENT=_CLIENTKEY_ - GOTIFY_SERVER=gotify diff --git a/alertify.py b/alertify.py new file mode 100644 index 0000000..35855c8 --- /dev/null +++ b/alertify.py @@ -0,0 +1,79 @@ +#!/usr/bin/python3 +""" +Main entrypoint to run Alertify +""" + +import argparse +import logging +import os +import sys + +from src import alertify + +if __name__ == '__main__': + + def parse_cli(): + """ + Function to parse the CLI + """ + maxlen = max([len(key) for key in alertify.Config.defaults()]) + defaults = [ + f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})' + for key, val in alertify.Config.defaults().items() + ] + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Bridge between Prometheus Alertmanager and Gotify\n', + epilog='The following environment variables will override any config or default:\n' + + '\n'.join(defaults), + ) + + parser.add_argument( + '-c', + '--config', + default=f'{os.path.splitext(__file__)[0]}.yaml', + help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)', + ) + + parser.add_argument( + '-H', + '--healthcheck', + action='store_true', + help='simply exit with 0 for healthy or 1 when unhealthy', + ) + + return parser.parse_args() + + def main(): + """ + main() + """ + logging.basicConfig( + format='%(levelname)s: %(message)s', + level=logging.INFO, + ) + + args = parse_cli() + + # forwarder = alertify.Alertify(args.config) + forwarder = alertify.Alertify() + + if forwarder.config.verbose: + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + + if args.healthcheck: + # Invert the sense of 'healthy' for Unix CLI usage + return not forwarder.healthcheck.report() + + if forwarder.config.verbose: + logging.debug('Parsed config:') + for key, val in forwarder.config.items(): + logging.debug('%s: %s', key, val) + + forwarder.server.listen_and_run() + + return 0 + + sys.exit(main()) diff --git a/src/alertify.py b/src/alertify.py deleted file mode 100644 index 5328ebd..0000000 --- a/src/alertify.py +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env python3 -""" -Module to act as a bridge between Prometheus Alertmanager and Gotify -""" - -import argparse -import functools -import json -import logging -import os -import sys -from distutils.util import strtobool -from http.server import HTTPServer, SimpleHTTPRequestHandler - -import yaml - -import gotify - -DEFAULTS = { - 'delete_onresolve': bool(False), - 'disable_resolved': bool(False), - 'gotify_client': str(), - 'gotify_key': str(), - 'gotify_port': int(80), - 'gotify_server': str('localhost'), - 'listen_port': int(8080), - 'verbose': bool(False), -} - - -class HTTPHandler(SimpleHTTPRequestHandler): - """ - Class to handle the HTTP requests from a client - """ - - config = None - - def _alerts(self): - """ - Method to handle the request for alerts - """ - if not healthy(self.config): - logging.error('Check requirements') - self._respond(500, 'Server not configured correctly') - return - - content_length = int(self.headers['Content-Length']) - rawdata = self.rfile.read(content_length) - - try: - am_msg = json.loads(rawdata.decode()) - except json.decoder.JSONDecodeError as error: - logging.error('Bad JSON: %s', error) - self._respond(400, f'Bad JSON: {error}') - return - - logging.debug('Received from Alertmanager:\n%s', json.dumps(am_msg, indent=2)) - - gotify_client = gotify.Gotify( - self.config.get('gotify_server'), - self.config.get('gotify_port'), - self.config.get('gotify_key'), - self.config.get('gotify_client'), - ) - - for alert in am_msg['alerts']: - try: - if alert['status'] == 'resolved': - if self.config.get('disable_resolved'): - logging.info('Ignoring resolved messages') - self._respond( - 200, - 'Ignored. "resolved" messages are disabled', - ) - continue - - if self.config.get('delete_onresolve'): - alert_id = gotify_client.find_byfingerprint(alert) - if alert_id: - response = gotify_client.delete(alert_id) - continue - logging.debug('Could not find a matching message to delete.') - - prefix = 'Resolved' - else: - prefix = alert['labels'].get('severity', 'warning').capitalize() - - gotify_msg = { - 'title': '{}: {}'.format( - prefix, - alert['annotations'].get('summary'), - ), - 'message': '{}: {}'.format( - alert['labels'].get('instance', '[unknown]'), - alert['annotations'].get('description', '[nodata]'), - ), - 'priority': int(alert['labels'].get('priority', 5)), - 'extras': { - 'alertify': { - 'fingerprint': alert.get('fingerprint', None), - } - }, - } - except KeyError as error: - logging.error('KeyError: %s', error) - self._respond(400, f'Missing field: {error}') - return - - response = gotify_client.send_alert(gotify_msg) - - try: - self._respond(response['status'], response['reason']) - except UnboundLocalError: - self._respond('204', '') - - def _respond(self, status, message): - """ - Method to output a simple HTTP status and string to the client - """ - self.send_response(int(status) or 500) - self.end_headers() - self.wfile.write(bytes(str(message).encode())) - - # Override built-in method - def do_GET(self): # pylint: disable=invalid-name - """ - Method to handle GET requests - """ - if self.path == '/healthcheck': - if not healthy(self.config): - logging.error('Check requirements') - self._respond(500, 'ERR') - - self._respond(200, 'OK') - - # Override built-in method - def do_POST(self): # pylint: disable=invalid-name - """ - Method to handle POST requests from AlertManager - """ - if self.path == '/alert': - self._alerts() - - # FIXME: This isn't right. A normal method doesn't work, however. - @classmethod - def set_config(cls, config): - """ - Classmethod to add config to the class - """ - cls.config = config - - -def healthy(config): - """ - Simple function to return if all the requirements are met - """ - return all( - [ - len(config.get('gotify_key', '')), - ] - ) - - -@functools.lru_cache -def parse_config(configfile): - """ - Function to parse a configuration file - """ - config = {} - - try: - with open(configfile, 'r') as file: - parsed = yaml.safe_load(file.read()) - except FileNotFoundError as error: - logging.warning('No config file found (%s)', error.filename) - parsed = {} - - # Iterate over the DEFAULTS dictionary and check for environment variables - # of the same name, then check for any items in the YAML config, otherwise - # use the default values. - # Ensure the types are adhered to. - for key, val in DEFAULTS.items(): - config[key] = os.environ.get(key.upper(), parsed.get(key, val)) - if isinstance(val, bool): - config[key] = strtobool(str(config[key])) - else: - config[key] = type(val)(config[key]) - - return config - - -if __name__ == '__main__': - - def parse_cli(): - """ - Function to parse the CLI - """ - maxlen = max([len(key) for key in DEFAULTS]) - defaults = [ - f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})' - for key, val in DEFAULTS.items() - ] - - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description='Bridge between Prometheus Alertmanager and Gotify\n', - epilog='The following environment variables will override any config or default:\n' - + '\n'.join(defaults), - ) - - parser.add_argument( - '-c', - '--config', - default=f'{os.path.splitext(__file__)[0]}.yaml', - help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)', - ) - - parser.add_argument( - '-H', - '--healthcheck', - action='store_true', - help='simply exit with 0 for healthy or 1 when unhealthy', - ) - - return parser.parse_args() - - def main(): - """ - main() - """ - logging.basicConfig( - format='%(levelname)s: %(message)s', - level=logging.INFO, - ) - - args = parse_cli() - config = parse_config(args.config) - - if config.get('verbose'): - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - - if args.healthcheck: - # Invert the sense of 'healthy' for Unix CLI usage - return not healthy(config) - - listen_port = config.get('listen_port') - - logging.debug( - 'Config:\n%s', - yaml.dump(config, explicit_start=True, default_flow_style=False), - ) - - logging.info('Starting web server on port %d', listen_port) - try: - with HTTPServer(('', listen_port), HTTPHandler) as webserver: - HTTPHandler.set_config(config) - webserver.serve_forever() - except KeyboardInterrupt: - logging.info('Exiting') - - return 0 - - sys.exit(main()) diff --git a/src/alertify/__init__.py b/src/alertify/__init__.py new file mode 100644 index 0000000..d150d37 --- /dev/null +++ b/src/alertify/__init__.py @@ -0,0 +1,41 @@ +""" +Alertify module to act as a bridge between Prometheus Alertmanager and Gotify +""" + +__author__ = 'Scott Wallace' +__email__ = 'scott@wallace.sh' +__maintainer__ = 'Scott Wallace' + +__version__ = '1.5' + +from .config import Config +from .gotify import Gotify +from .server import Server +from .healthcheck import Healthcheck +from .messaging import MessageHandler + + +class Alertify: + """ + Class for Alertify + """ + + def __init__(self, configfile=None): + self.config = Config(configfile) + self.gotify = Gotify( + self.config.gotify_server, + self.config.gotify_port, + self.config.gotify_key, + self.config.gotify_client, + ) + self.message_handler = MessageHandler( + self.gotify, + self.config.disable_resolved, + self.config.delete_onresolve, + ) + self.healthcheck = Healthcheck(self.gotify) + self.server = Server( + self.config.listen_port, + self.message_handler, + self.healthcheck, + ) diff --git a/src/alertify/config.py b/src/alertify/config.py new file mode 100644 index 0000000..948eab6 --- /dev/null +++ b/src/alertify/config.py @@ -0,0 +1,81 @@ +""" +Module to handle Alertify's configuration +""" +import inspect +import logging +import os +from distutils.util import strtobool + +import yaml + + +class Config: + """ + Class to handle the config + """ + + delete_onresolve = bool(False) + disable_resolved = bool(False) + gotify_client = str() + gotify_key = str() + gotify_port = int(80) + gotify_server = str('localhost') + listen_port = int(8080) + verbose = bool(False) + + def __init__(self, configfile=None): + """ + Method to parse a configuration file + """ + logging.debug('Parsing config') + parsed = {} + + try: + with open(configfile, 'r') as file: + parsed = yaml.safe_load(file.read()) + except FileNotFoundError as error: + logging.warning('No config file found (%s)', error.filename) + except TypeError: + logging.warning('No config file provided.') + + # Iterate over the config defaults and check for environment variable + # overrides, then check for any items in the config, otherwise + # use the default values. + for key, default_val in self.defaults().items(): + userval = os.environ.get(key.upper(), parsed.get(key, default_val)) + + # Ensure the types are adhered to. + if isinstance(default_val, bool): + setattr(self, key, strtobool(str(userval))) + else: + setattr(self, key, type(default_val)(userval)) + + def items(self): + """ + Method to return an iterator for the configured items + """ + return {key: getattr(self, key) for key in self.__dict__}.items() + + @classmethod + def keys(cls): + """ + Method to return the defaults as a list of keys + """ + return [ + attr[0] + for attr in inspect.getmembers(cls) + if not attr[0].startswith('_') + and not any( + [ + inspect.ismethod(attr[1]), + callable(attr[1]), + ] + ) + ] + + @classmethod + def defaults(cls): + """ + Classmethod to return the defaults as a dictionary + """ + return {key: getattr(cls, key) for key in cls.keys()} diff --git a/src/gotify.py b/src/alertify/gotify.py similarity index 95% rename from src/gotify.py rename to src/alertify/gotify.py index f14e147..9738aa2 100644 --- a/src/gotify.py +++ b/src/alertify/gotify.py @@ -96,7 +96,7 @@ class Gotify: Method to return a list of messages from the Gotify server """ if not self.client_key: - logging.debug( + logging.warning( 'No client key is configured. No messages could be retrieved.' ) return [] @@ -109,3 +109,9 @@ class Gotify: """ logging.debug('Sending message to Gotify') return self._call('POST', '/message', body=json.dumps(payload, indent=2)) + + def healthcheck(self): + """ + Method to perform a healthcheck against Gotify + """ + return self._call('GET', '/health') diff --git a/src/alertify/healthcheck.py b/src/alertify/healthcheck.py new file mode 100644 index 0000000..5be876a --- /dev/null +++ b/src/alertify/healthcheck.py @@ -0,0 +1,29 @@ +""" +Module for handling any healthcheck related activity +""" + + +class Healthcheck: + """ + Class to handle the healthchecks + """ + + def __init__(self, gotify_client): + self.gotify = gotify_client + + def report(self): + """ + Simple method to return a boolean state of the general health + """ + + return all( + [ + self.gotify.healthcheck(), + ] + ) + + def gotify_alive(self): + """ + Simple method to return the Gotify healthcheck response + """ + return self.gotify.healthcheck() diff --git a/src/alertify/messaging.py b/src/alertify/messaging.py new file mode 100644 index 0000000..8a4981c --- /dev/null +++ b/src/alertify/messaging.py @@ -0,0 +1,63 @@ +""" +Module for handling the messaging +""" +import logging + + +class MessageHandler: + """ + Class to handle alert messaging + """ + + def __init__(self, gotify_client, disable_resolved=False, delete_onresolve=False): + self.gotify = gotify_client + self.disable_resolved = disable_resolved + self.delete_onresolve = delete_onresolve + + def process(self, alert): + """ + Method to process the alert message + """ + try: + if alert['status'] == 'resolved': + if self.disable_resolved: + logging.info('Ignoring resolved messages') + return { + 'status': 200, + 'reason': 'Ignored. "resolved" messages are disabled', + } + + if self.delete_onresolve: + alert_id = self.gotify.find_byfingerprint(alert) + if alert_id: + return self.gotify.delete(alert_id) + logging.warning('Could not find a matching message to delete.') + + prefix = 'resolved' + else: + prefix = alert['labels'].get('severity', 'warning') + + gotify_msg = { + 'title': '[{}] {}'.format( + prefix.upper(), + alert['annotations'].get('summary'), + ), + 'message': '{}: {}'.format( + alert['labels'].get('instance', '[unknown]'), + alert['annotations'].get('description', '[nodata]'), + ), + 'priority': int(alert['labels'].get('priority', 5)), + 'extras': { + 'alertify': { + 'fingerprint': alert.get('fingerprint', None), + } + }, + } + except KeyError as error: + logging.error('KeyError: %s', error) + return { + 'status': 400, + 'reason': f'Missing field: {error}', + } + + return self.gotify.send_alert(gotify_msg) diff --git a/src/alertify/server.py b/src/alertify/server.py new file mode 100644 index 0000000..6f6921b --- /dev/null +++ b/src/alertify/server.py @@ -0,0 +1,80 @@ +""" +Module to act as a bridge between Prometheus Alertmanager and Gotify +""" + +import json +import logging +from http.server import HTTPServer, SimpleHTTPRequestHandler + + +class Server: + """ + Class to handle the webserver for Alertify + """ + + def __init__(self, port, message_handler, healthcheck): + self.port = port + self.message_handler = message_handler + self.healthcheck = healthcheck + + def listen_and_run(self): + """ + Method to bind to the port and run indefinitely + """ + logging.info('Starting web server on port %d', self.port) + + # FIXME: Find a better way to handle the injection of these values + http_handler = self.HTTPHandler + http_handler.message_handler = self.message_handler + http_handler.healthcheck = self.healthcheck + + try: + with HTTPServer(('', self.port), http_handler) as webserver: + webserver.serve_forever() + except KeyboardInterrupt: + logging.info('Exiting') + + class HTTPHandler(SimpleHTTPRequestHandler): + """ + Class to handle the HTTP requests from a client + """ + + def _respond(self, status, message): + """ + Method to output a simple HTTP status and string to the client + """ + self.send_response(int(status) or 500) + self.end_headers() + self.wfile.write(bytes(str(message).encode())) + + def do_GET(self): # pylint: disable=invalid-name + """ + Method to handle GET requests + """ + if self.path == '/healthcheck': + response = self.healthcheck.gotify_alive() + self._respond(response['status'], response['reason']) + + def do_POST(self): # pylint: disable=invalid-name + """ + Method to handle POST requests from AlertManager + """ + if self.path == '/alert': + try: + content_length = int(self.headers['Content-Length']) + message = json.loads(self.rfile.read(content_length).decode()) + except json.decoder.JSONDecodeError as error: + logging.error('Bad JSON: %s', error) + self._respond(400, f'Bad JSON: {error}') + + logging.debug( + 'Received from Alertmanager:\n%s', + json.dumps(message, indent=2), + ) + + for alert in message['alerts']: + response = self.message_handler.process(alert) + try: + self._respond(response['status'], response['reason']) + except UnboundLocalError: + self._respond('204', '')