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

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
flask
flask-classful
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
"""
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):

View file

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

View file

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

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"""
import unittest
from alertify import config # pylint: disable=import-error
from Alertify import config # pylint: disable=import-error
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
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 = [

View file

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

View file

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

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