From ed5ba680daa98c11d48f0d7b4c446b49cb346947 Mon Sep 17 00:00:00 2001
From: Scott Wallace <scott@wallace.sh>
Date: Sat, 7 Nov 2020 17:31:31 +0000
Subject: [PATCH] Move to Flask to fix #17

---
 alertify.py                                   | 23 ++---
 requirements.txt                              |  2 +
 src/Alertify/__init__.py                      | 96 +++++++++++++++++++
 src/{alertify => Alertify}/config.py          |  0
 src/{alertify => Alertify}/gotify.py          |  2 +-
 .../healthcheck.py => Alertify/health.py}     | 10 --
 src/{alertify => Alertify}/messaging.py       | 10 +-
 src/alertify/__init__.py                      | 41 --------
 src/alertify/server.py                        | 81 ----------------
 src/tests/Alertify/test___init__.py           | 68 +++++++++++++
 .../{alertify => Alertify}/test_config.py     |  2 +-
 .../{alertify => Alertify}/test_gotify.py     |  6 +-
 .../test_health.py}                           | 21 +---
 .../{alertify => Alertify}/test_messaging.py  |  4 +-
 src/tests/alertify/test___init__.py           | 22 -----
 src/tests/alertify/test_server.py             | 32 -------
 16 files changed, 195 insertions(+), 225 deletions(-)
 create mode 100644 src/Alertify/__init__.py
 rename src/{alertify => Alertify}/config.py (100%)
 rename src/{alertify => Alertify}/gotify.py (98%)
 rename src/{alertify/healthcheck.py => Alertify/health.py} (63%)
 rename src/{alertify => Alertify}/messaging.py (88%)
 delete mode 100644 src/alertify/__init__.py
 delete mode 100644 src/alertify/server.py
 create mode 100644 src/tests/Alertify/test___init__.py
 rename src/tests/{alertify => Alertify}/test_config.py (94%)
 rename src/tests/{alertify => Alertify}/test_gotify.py (94%)
 rename src/tests/{alertify/test_healthcheck.py => Alertify/test_health.py} (51%)
 rename src/tests/{alertify => Alertify}/test_messaging.py (89%)
 delete mode 100644 src/tests/alertify/test___init__.py
 delete mode 100644 src/tests/alertify/test_server.py

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())