diff --git a/alertify.py b/alertify.py index 89ad354..6cb03b5 100644 --- a/alertify.py +++ b/alertify.py @@ -8,7 +8,7 @@ import logging import os import sys -from src import alertify +from src import Alertify if __name__ == '__main__': @@ -16,10 +16,10 @@ if __name__ == '__main__': """ Function to parse the CLI """ - maxlen = max([len(key) for key in alertify.Config.defaults()]) + 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() + for key, val in Alertify.Config.defaults().items() ] parser = argparse.ArgumentParser( @@ -56,8 +56,8 @@ if __name__ == '__main__': args = parse_cli() - # forwarder = alertify.Alertify(args.config) - forwarder = alertify.Alertify() + alertify = Alertify.Alertify() + alertify.configure(args.config) # ----------------------------- # Calculate logging level @@ -65,21 +65,22 @@ if __name__ == '__main__': # Config :: Verbose: 0 = WARNING, 1 = INFO, 2 = DEBUG # Logging :: Loglevel: 30 = WARNING, 20 = INFO, 10 = DEBUG logger = logging.getLogger() - logger.setLevel(max(logging.WARNING - (forwarder.config.verbose * 10), 10)) + logger.setLevel(max(logging.WARNING - (alertify.config.verbose * 10), 10)) # ----------------------------- if args.healthcheck: # Invert the sense of 'healthy' for Unix CLI usage - return not forwarder.healthcheck.report() + _, status = alertify.healthcheck() + return status == 200 - logging.info('Version: %s', alertify.__version__) + logging.info('Version: %s', Alertify.__version__) - if forwarder.config.verbose: + if alertify.config.verbose: logging.debug('Parsed config:') - for key, val in forwarder.config.items(): + for key, val in alertify.config.items(): logging.debug('%s: %s', key, val) - forwarder.server.listen_and_run() + alertify.run() return 0 diff --git a/requirements.txt b/requirements.txt index b06eea0..b830588 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ # To ensure app dependencies are ported from your virtual environment/host machine into your container, run 'pip freeze > requirements.txt' in the terminal to overwrite this file +flask +flask-classful pyyaml diff --git a/src/Alertify/__init__.py b/src/Alertify/__init__.py new file mode 100644 index 0000000..7ad7959 --- /dev/null +++ b/src/Alertify/__init__.py @@ -0,0 +1,96 @@ +""" +Alertify module to act as a bridge between Prometheus Alertmanager and Gotify +""" +# pylint: disable=invalid-name + +__author__ = 'Scott Wallace' +__email__ = 'scott@wallace.sh' +__maintainer__ = 'Scott Wallace' + +__version__ = '2.0' + +import json +import logging + +import werkzeug.exceptions +from flask import Flask, request, request_started +from flask_classful import FlaskView, route + +from .config import Config +from .gotify import Gotify +from .health import Healthcheck +from .messaging import MessageHandler + +webapp = Flask(__name__) + + +class Alertify(FlaskView): + """ + Main alertify class + """ + + route_base = '/' + trailing_slash = False + + def __init__(self): + self.configure() + + def configure(self, configfile=None): + """ + Configure the object from a configfile + """ + try: + _ = request.args + raise werkzeug.exceptions.NotFound + except RuntimeError: + self.config = Config(configfile) + self.gotify = Gotify( + self.config.gotify_server, + self.config.gotify_port, + self.config.gotify_key_app, + self.config.gotify_key_client, + ) + self.msg_hndlr = MessageHandler( + self.gotify, + self.config.disable_resolved, + self.config.delete_onresolve, + ) + + def run(self): + """ + Listen on port and run webserver + """ + try: + _ = request.args + raise werkzeug.exceptions.NotFound + except RuntimeError: + webapp.run(host='0.0.0.0', port=self.config.listen_port) + + @route('/alert', methods=['POST']) + def alert(self): + """ + Handle the alerts from Alertmanager + """ + message = request.get_json() + + logging.debug( + 'Received from Alertmanager:\n%s', + json.dumps(message, indent=2), + ) + + for alertmsg in message['alerts']: + response = self.msg_hndlr.process(alertmsg) + try: + return response['reason'], response['status'] + except UnboundLocalError: + return '', 204 + + def healthcheck(self): + """ + Perform a healthcheck and return the results + """ + response = Healthcheck(self.gotify).gotify_alive() + return response['reason'], response['status'] + + +Alertify.register(webapp) diff --git a/src/alertify/config.py b/src/Alertify/config.py similarity index 100% rename from src/alertify/config.py rename to src/Alertify/config.py diff --git a/src/alertify/gotify.py b/src/Alertify/gotify.py similarity index 98% rename from src/alertify/gotify.py rename to src/Alertify/gotify.py index 0dbb841..2ae6e0a 100644 --- a/src/alertify/gotify.py +++ b/src/Alertify/gotify.py @@ -64,7 +64,7 @@ class Gotify: """ Method to delete a message from the Gotify server """ - logging.info('Deleting message ID: %s', msg_id) + logging.debug('Deleting message ID: %s', msg_id) return self._call('DELETE', f'/message/{msg_id}') def find_byfingerprint(self, message): diff --git a/src/alertify/healthcheck.py b/src/Alertify/health.py similarity index 63% rename from src/alertify/healthcheck.py rename to src/Alertify/health.py index 541f19b..f96abb1 100644 --- a/src/alertify/healthcheck.py +++ b/src/Alertify/health.py @@ -11,16 +11,6 @@ class Healthcheck: 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_alive(), - ] - ) - def gotify_alive(self): """ Simple method to return the Gotify healthcheck response diff --git a/src/alertify/messaging.py b/src/Alertify/messaging.py similarity index 88% rename from src/alertify/messaging.py rename to src/Alertify/messaging.py index cb638bb..993f39f 100644 --- a/src/alertify/messaging.py +++ b/src/Alertify/messaging.py @@ -30,11 +30,11 @@ class MessageHandler: if self.delete_onresolve: for alert_id in self.gotify.find_byfingerprint(alert): if not self.gotify.delete(alert_id): - logging.error('There was a problem removing message ID %d', alert_id) - return { - 'status': 200, - 'reason': 'Message deletion complete' - } + logging.error( + 'There was a problem removing message ID %d', + alert_id, + ) + return {'status': 200, 'reason': 'Message deletion complete'} prefix = 'resolved' else: diff --git a/src/alertify/__init__.py b/src/alertify/__init__.py deleted file mode 100644 index 045c549..0000000 --- a/src/alertify/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Alertify module to act as a bridge between Prometheus Alertmanager and Gotify -""" - -__author__ = 'Scott Wallace' -__email__ = 'scott@wallace.sh' -__maintainer__ = 'Scott Wallace' - -__version__ = '1.6' - -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_app, - self.config.gotify_key_app, - ) - 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/server.py b/src/alertify/server.py deleted file mode 100644 index fd2658e..0000000 --- a/src/alertify/server.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -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() - return True - 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', '') diff --git a/src/tests/Alertify/test___init__.py b/src/tests/Alertify/test___init__.py new file mode 100644 index 0000000..d8294f2 --- /dev/null +++ b/src/tests/Alertify/test___init__.py @@ -0,0 +1,68 @@ +"""Test""" +import unittest +from unittest.mock import patch + +import flask + +import Alertify # pylint: disable=import-error + + +class AlertifyTest(unittest.TestCase): + """ + Tests for methods in the Alertify class. + """ + + @classmethod + def setUpClass(cls): + cls.alertify = Alertify.Alertify() + + @classmethod + def tearDownClass(cls): + pass + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_configure(self): + """Test""" + self.alertify.configure(None) + self.assertDictEqual( + self.alertify.config.defaults(), + Alertify.Config.defaults(), + ) + + @patch('Alertify.messaging.MessageHandler.process') + def test_alert(self, mock_process): + """Test""" + mock_process.return_value = { + 'status': 200, + 'reason': 'OK', + 'json': None, + } + + with flask.Flask(__name__).test_request_context( + '/alert', + data='{"alerts": []}', + headers={'Content-type': 'application/json'}, + ): + self.assertTupleEqual( + self.alertify.alert(), + ('', 204), + ) + + @patch('Alertify.health.Healthcheck.gotify_alive') + def test_healthcheck(self, mock_gotify_alive): + """Test""" + mock_gotify_alive.return_value = { + 'status': 200, + 'reason': 'OK', + 'json': None, + } + + self.assertTupleEqual( + self.alertify.healthcheck(), + ('OK', 200), + ) diff --git a/src/tests/alertify/test_config.py b/src/tests/Alertify/test_config.py similarity index 94% rename from src/tests/alertify/test_config.py rename to src/tests/Alertify/test_config.py index 242285e..297b7ae 100644 --- a/src/tests/alertify/test_config.py +++ b/src/tests/Alertify/test_config.py @@ -1,7 +1,7 @@ """Test""" import unittest -from alertify import config # pylint: disable=import-error +from Alertify import config # pylint: disable=import-error class ConfigTest(unittest.TestCase): diff --git a/src/tests/alertify/test_gotify.py b/src/tests/Alertify/test_gotify.py similarity index 94% rename from src/tests/alertify/test_gotify.py rename to src/tests/Alertify/test_gotify.py index 81f90b8..b0bf35e 100644 --- a/src/tests/alertify/test_gotify.py +++ b/src/tests/Alertify/test_gotify.py @@ -1,10 +1,10 @@ """ -Module to handle unit tests for the alertify.gotify module +Module to handle unit tests for the Alertify.gotify module """ import unittest from unittest.mock import patch -from alertify import gotify # pylint: disable=import-error +from Alertify import gotify # pylint: disable=import-error class GotifyTest(unittest.TestCase): @@ -45,7 +45,7 @@ class GotifyTest(unittest.TestCase): }, ) - @patch('alertify.gotify.Gotify.messages') + @patch('Alertify.gotify.Gotify.messages') def test_find_byfingerprint(self, mock_messages): """Test""" mock_messages.return_value = [ diff --git a/src/tests/alertify/test_healthcheck.py b/src/tests/Alertify/test_health.py similarity index 51% rename from src/tests/alertify/test_healthcheck.py rename to src/tests/Alertify/test_health.py index 42223c2..13424ab 100644 --- a/src/tests/alertify/test_healthcheck.py +++ b/src/tests/Alertify/test_health.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch -from alertify import healthcheck, gotify # pylint: disable=import-error +from Alertify import gotify, health # pylint: disable=import-error class HealthcheckTest(unittest.TestCase): @@ -12,7 +12,7 @@ class HealthcheckTest(unittest.TestCase): @classmethod def setUpClass(cls): - cls.healthcheck = healthcheck.Healthcheck(gotify.Gotify('', 0, '', '')) + cls.healthcheck = health.Healthcheck(gotify.Gotify('', 0, '', '')) @classmethod def tearDownClass(cls): @@ -24,21 +24,10 @@ class HealthcheckTest(unittest.TestCase): def tearDown(self): pass - @patch('alertify.healthcheck.Healthcheck.gotify_alive') - def test_report(self, mock_healthcheck): + @patch('Alertify.health.Healthcheck.gotify_alive') + def test_gotify_alive(self, mock_gotify_alive): """Test""" - mock_healthcheck.return_value = { - 'status': 200, - 'reason': 'OK', - 'json': None, - } - - self.assertTrue(self.healthcheck.report()) - - @patch('alertify.healthcheck.Healthcheck.gotify_alive') - def test_gotify_alive(self, mock_healthcheck): - """Test""" - mock_healthcheck.return_value = { + mock_gotify_alive.return_value = { 'status': 200, 'reason': 'OK', 'json': None, diff --git a/src/tests/alertify/test_messaging.py b/src/tests/Alertify/test_messaging.py similarity index 89% rename from src/tests/alertify/test_messaging.py rename to src/tests/Alertify/test_messaging.py index 73517e2..4a14248 100644 --- a/src/tests/alertify/test_messaging.py +++ b/src/tests/Alertify/test_messaging.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch -from alertify import messaging, gotify # pylint: disable=import-error +from Alertify import gotify, messaging # pylint: disable=import-error class MessageHandlerTest(unittest.TestCase): @@ -24,7 +24,7 @@ class MessageHandlerTest(unittest.TestCase): def tearDown(self): pass - @patch('alertify.gotify.Gotify.send_alert') + @patch('Alertify.gotify.Gotify.send_alert') def test_process(self, mock_send_alert): """Test""" mock_send_alert.return_value = { diff --git a/src/tests/alertify/test___init__.py b/src/tests/alertify/test___init__.py deleted file mode 100644 index f45cfc0..0000000 --- a/src/tests/alertify/test___init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Test""" -import unittest - - -class AlertifyTest(unittest.TestCase): - """ - Tests for methods in the Alertify class. - """ - - @classmethod - def setUpClass(cls): - pass - - @classmethod - def tearDownClass(cls): - pass - - def setUp(self): - pass - - def tearDown(self): - pass diff --git a/src/tests/alertify/test_server.py b/src/tests/alertify/test_server.py deleted file mode 100644 index 9920560..0000000 --- a/src/tests/alertify/test_server.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Test""" -import unittest -from unittest.mock import patch - -from alertify import server # pylint: disable=import-error - - -class ServerTest(unittest.TestCase): - """ - Tests for methods in the Server class. - """ - - @classmethod - def setUpClass(cls): - cls.server = server.Server(0, None, None) - - @classmethod - def tearDownClass(cls): - pass - - def setUp(self): - pass - - def tearDown(self): - pass - - @patch('http.server.HTTPServer.serve_forever') - def test_listen_and_run(self, mock_serve_forever): - """Test""" - mock_serve_forever.return_value = True - - self.assertTrue(self.server.listen_and_run())