Move to Flask to fix #17

This commit is contained in:
Scott Wallace 2020-11-07 17:31:31 +00:00
parent e0a6c37031
commit ed5ba680da
16 changed files with 195 additions and 225 deletions

View file

@ -8,7 +8,7 @@ import logging
import os import os
import sys import sys
from src import alertify from src import Alertify
if __name__ == '__main__': if __name__ == '__main__':
@ -16,10 +16,10 @@ if __name__ == '__main__':
""" """
Function to parse the CLI 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 = [ defaults = [
f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})' 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( parser = argparse.ArgumentParser(
@ -56,8 +56,8 @@ if __name__ == '__main__':
args = parse_cli() args = parse_cli()
# forwarder = alertify.Alertify(args.config) alertify = Alertify.Alertify()
forwarder = alertify.Alertify() alertify.configure(args.config)
# ----------------------------- # -----------------------------
# Calculate logging level # Calculate logging level
@ -65,21 +65,22 @@ if __name__ == '__main__':
# Config :: Verbose: 0 = WARNING, 1 = INFO, 2 = DEBUG # Config :: Verbose: 0 = WARNING, 1 = INFO, 2 = DEBUG
# Logging :: Loglevel: 30 = WARNING, 20 = INFO, 10 = DEBUG # Logging :: Loglevel: 30 = WARNING, 20 = INFO, 10 = DEBUG
logger = logging.getLogger() 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: if args.healthcheck:
# Invert the sense of 'healthy' for Unix CLI usage # 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:') logging.debug('Parsed config:')
for key, val in forwarder.config.items(): for key, val in alertify.config.items():
logging.debug('%s: %s', key, val) logging.debug('%s: %s', key, val)
forwarder.server.listen_and_run() alertify.run()
return 0 return 0

View file

@ -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 # 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 pyyaml

96
src/Alertify/__init__.py Normal file
View file

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

View file

@ -64,7 +64,7 @@ class Gotify:
""" """
Method to delete a message from the Gotify server 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}') return self._call('DELETE', f'/message/{msg_id}')
def find_byfingerprint(self, message): def find_byfingerprint(self, message):

View file

@ -11,16 +11,6 @@ class Healthcheck:
def __init__(self, gotify_client): def __init__(self, gotify_client):
self.gotify = 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): def gotify_alive(self):
""" """
Simple method to return the Gotify healthcheck response Simple method to return the Gotify healthcheck response

View file

@ -30,11 +30,11 @@ class MessageHandler:
if self.delete_onresolve: if self.delete_onresolve:
for alert_id in self.gotify.find_byfingerprint(alert): for alert_id in self.gotify.find_byfingerprint(alert):
if not self.gotify.delete(alert_id): if not self.gotify.delete(alert_id):
logging.error('There was a problem removing message ID %d', alert_id) logging.error(
return { 'There was a problem removing message ID %d',
'status': 200, alert_id,
'reason': 'Message deletion complete' )
} return {'status': 200, 'reason': 'Message deletion complete'}
prefix = 'resolved' prefix = 'resolved'
else: else:

View file

@ -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,
)

View file

@ -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', '')

View file

@ -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),
)

View file

@ -1,7 +1,7 @@
"""Test""" """Test"""
import unittest import unittest
from alertify import config # pylint: disable=import-error from Alertify import config # pylint: disable=import-error
class ConfigTest(unittest.TestCase): class ConfigTest(unittest.TestCase):

View file

@ -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 import unittest
from unittest.mock import patch 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): 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): def test_find_byfingerprint(self, mock_messages):
"""Test""" """Test"""
mock_messages.return_value = [ mock_messages.return_value = [

View file

@ -2,7 +2,7 @@
import unittest import unittest
from unittest.mock import patch 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): class HealthcheckTest(unittest.TestCase):
@ -12,7 +12,7 @@ class HealthcheckTest(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.healthcheck = healthcheck.Healthcheck(gotify.Gotify('', 0, '', '')) cls.healthcheck = health.Healthcheck(gotify.Gotify('', 0, '', ''))
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
@ -24,21 +24,10 @@ class HealthcheckTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
pass pass
@patch('alertify.healthcheck.Healthcheck.gotify_alive') @patch('Alertify.health.Healthcheck.gotify_alive')
def test_report(self, mock_healthcheck): def test_gotify_alive(self, mock_gotify_alive):
"""Test""" """Test"""
mock_healthcheck.return_value = { mock_gotify_alive.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 = {
'status': 200, 'status': 200,
'reason': 'OK', 'reason': 'OK',
'json': None, 'json': None,

View file

@ -2,7 +2,7 @@
import unittest import unittest
from unittest.mock import patch 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): class MessageHandlerTest(unittest.TestCase):
@ -24,7 +24,7 @@ class MessageHandlerTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
pass pass
@patch('alertify.gotify.Gotify.send_alert') @patch('Alertify.gotify.Gotify.send_alert')
def test_process(self, mock_send_alert): def test_process(self, mock_send_alert):
"""Test""" """Test"""
mock_send_alert.return_value = { mock_send_alert.return_value = {

View file

@ -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

View file

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