Compare commits

...

16 commits
1.6 ... main

19 changed files with 544 additions and 204 deletions

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.5, 3.6, 3.7, 3.8]
python-version: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
.pyenv/
__pycache__/
.pyenv/
.pytest_cache/

View file

@ -13,18 +13,18 @@ optional arguments:
-H, --healthcheck simply exit with 0 for healthy or 1 when unhealthy
The following environment variables will override any config or default:
* DELETE_ONRESOLVE (default: False)
* DISABLE_RESOLVED (default: False)
* GOTIFY_CLIENT (default: None)
* GOTIFY_KEY (default: None)
* GOTIFY_PORT (default: 80)
* GOTIFY_SERVER (default: localhost)
* LISTEN_PORT (default: 8080)
* VERBOSE (default: False)
* DELETE_ONRESOLVE (default: False)
* DISABLE_RESOLVED (default: False)
* GOTIFY_KEY_APP (default: None)
* GOTIFY_KEY_CLIENT (default: None)
* GOTIFY_URL_PREFIX (default: http://localhost)
* LISTEN_PORT (default: 8080)
* VERBOSE (default: 0)
```
# Notes
* Requires Python v3.6 or greater (f-strings are used)
* Listens on port 8080 by default.
* Forwards `resolved` alerts, if not disabled.
* Resolved alerts delete the original alert, if enabled.
@ -47,7 +47,7 @@ docker build . -t 'alertify:latest'
e.g.
```bash
docker run --name alertify -p 8080:8080 -e TZ=Europe/London -e GOTIFY_KEY=_APPKEY_ -e GOTIFY_SERVER=gotify -e GOTIFY_PORT=80 alertify:latest
docker run --name alertify -p 8080:8080 -e TZ=Europe/London -e GOTIFY_KEY_APP=_APPKEY_ -e GOTIFY_URL_PREFIX=http://gotify alertify:latest
```
## Compose:
@ -73,9 +73,8 @@ services:
environment:
- TZ=Europe/London
- DELETE_ONRESOLVE=true
- GOTIFY_KEY=_APPKEY_
- GOTIFY_CLIENT=_CLIENTKEY_
- GOTIFY_SERVER=gotify
- GOTIFY_PORT=80
- GOTIFY_KEY_APP=_APPKEY_
- GOTIFY_KEY_CLIENT=_CLIENTKEY_
- GOTIFY_URL_PREFIX=http://gotify
restart: unless-stopped
```

View file

@ -8,18 +8,18 @@ import logging
import os
import sys
from src import alertify
from src import Alertify
if __name__ == '__main__':
def parse_cli():
def parse_cli() -> argparse.ArgumentParser:
"""
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(
@ -45,9 +45,9 @@ if __name__ == '__main__':
return parser.parse_args()
def main():
def main() -> int:
"""
main()
Main program logic
"""
logging.basicConfig(
format='%(levelname)s: %(message)s',
@ -56,25 +56,31 @@ if __name__ == '__main__':
args = parse_cli()
# forwarder = alertify.Alertify(args.config)
forwarder = alertify.Alertify()
alertify = Alertify.Alertify()
alertify.configure(args.config)
if forwarder.config.verbose:
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# -----------------------------
# Calculate logging level
# -----------------------------
# Config :: Verbose: 0 = WARNING, 1 = INFO, 2 = DEBUG
# Logging :: Loglevel: 30 = WARNING, 20 = INFO, 10 = DEBUG
logger = logging.getLogger()
logger.setLevel(max(logging.WARNING - (alertify.config.verbose * 10), 10))
# -----------------------------
if args.healthcheck:
_, status = alertify.healthcheck()
# Invert the sense of 'healthy' for Unix CLI usage
return not forwarder.healthcheck.report()
return not 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,7 +1,6 @@
---
disable_resolved: false
gotify_key: sOmEsEcReTkEy1
gotify_port: "80"
gotify_server: gotifyserver.example.net
gotify_url_prefix: http://gotifyserver.example.net
listen_port: "8080"
verbose: true

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
requests

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

@ -0,0 +1,100 @@
"""
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
from typing import Optional, Tuple
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__)
# FIXME: * Find a better way to deny FlaskView methods without using a `_`
# prefix or raising a NotFound exception
class Alertify(FlaskView):
"""
Main Alertify class
"""
route_base = '/'
trailing_slash = False
def __init__(self):
# Instantiate with defaults
self.configure()
def configure(self, configfile: Optional[str] = None):
"""
Configure from a configfile
"""
# Deny via HTTP
if request:
raise werkzeug.exceptions.NotFound
self.config = Config(configfile)
self.gotify = Gotify(
self.config.gotify_url_prefix,
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
"""
# Deny via HTTP
if request:
raise werkzeug.exceptions.NotFound
webapp.run(host='0.0.0.0', port=self.config.listen_port)
@route('/alert', methods=['POST'])
def alert(self) -> Tuple[str, int]:
"""
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) -> Tuple[str, int]:
"""
Perform a healthcheck and return the results
"""
response = Healthcheck(self.gotify).gotify_alive()
return response['reason'], response['status']
Alertify.register(webapp)

View file

@ -5,6 +5,7 @@ import inspect
import logging
import os
from distutils.util import strtobool
from typing import Optional
import yaml
@ -16,14 +17,13 @@ class Config:
delete_onresolve = bool(False)
disable_resolved = bool(False)
gotify_client = str()
gotify_key = str()
gotify_port = int(80)
gotify_server = str('localhost')
gotify_key_app = str()
gotify_key_client = str()
gotify_url_prefix = str('http://localhost')
listen_port = int(8080)
verbose = bool(False)
verbose = int(0)
def __init__(self, configfile=None):
def __init__(self, configfile: Optional[str] = None):
"""
Method to parse a configuration file
"""
@ -50,16 +50,16 @@ class Config:
else:
setattr(self, key, type(default_val)(userval))
def items(self):
def items(self) -> list:
"""
Method to return an iterator for the configured items
"""
return {key: getattr(self, key) for key in self.__dict__}.items()
@classmethod
def keys(cls):
def keys(cls) -> list:
"""
Method to return the defaults as a list of keys
Method to return the defaults as a list of dict_keys
"""
return [
attr[0]
@ -74,7 +74,7 @@ class Config:
]
@classmethod
def defaults(cls):
def defaults(cls) -> dict:
"""
Classmethod to return the defaults as a dictionary
"""

View file

@ -1,10 +1,12 @@
"""
Module to handle communication with the Gotify server
"""
import http.client
import json
import logging
import socket
from typing import Optional
from requests import request
class Gotify:
@ -12,8 +14,8 @@ class Gotify:
Class to handle Gotify communications
"""
def __init__(self, server, port, app_key, client_key=None):
self.api = http.client.HTTPConnection(server, port)
def __init__(self, url_prefix: str, app_key: str, client_key: Optional[str] = None):
self.url_prefix = url_prefix
self.app_key = app_key
self.client_key = client_key
self.base_headers = {
@ -21,7 +23,7 @@ class Gotify:
'Accept': 'application/json',
}
def _call(self, method, url, body=None):
def _call(self, method: str, url: str, data: Optional[object] = None) -> dict:
"""
Method to call Gotify with an app or client key as appropriate
"""
@ -31,11 +33,15 @@ class Gotify:
else:
headers['X-Gotify-Key'] = self.app_key
logging.debug('Sending to Gotify:\n%s', body)
logging.debug('Sending to Gotify:\n%s', data)
try:
self.api.request(method, url, body=body, headers=headers)
response = self.api.getresponse()
response = request(
method,
f'{self.url_prefix}{url}',
json=data,
headers=headers,
)
except (ConnectionRefusedError, socket.gaierror) as error:
logging.error('Connection error: %s', error)
return {
@ -44,14 +50,13 @@ class Gotify:
}
resp_obj = {
'status': response.status,
'status': response.status_code,
'reason': response.reason,
'json': None,
}
rawbody = response.read()
if len(rawbody) > 0:
if len(response.content) > 0:
try:
resp_obj['json'] = json.loads(rawbody.decode())
resp_obj['json'] = json.loads(response.content.decode())
except json.decoder.JSONDecodeError as error:
logging.error('Could not parse JSON: %s', error)
@ -60,14 +65,14 @@ class Gotify:
return resp_obj
def delete(self, msg_id):
def delete(self, msg_id: str) -> dict:
"""
Method to delete a message from the Gotify server
"""
logging.debug('Deleting message ID: %s', msg_id)
return self._call('DELETE', f'/message/{msg_id}')
def find_byfingerprint(self, message):
def find_byfingerprint(self, message: str) -> list:
"""
Method to return the ID of a matching message
"""
@ -75,7 +80,7 @@ class Gotify:
new_fingerprint = message['fingerprint']
except KeyError:
logging.debug('No fingerprint found in new message')
return None
return list()
msg_list = []
for old_message in self.messages():
@ -91,7 +96,7 @@ class Gotify:
return msg_list
def messages(self):
def messages(self) -> dict:
"""
Method to return a list of messages from the Gotify server
"""
@ -99,18 +104,18 @@ class Gotify:
logging.warning(
'No client key is configured. No messages could be retrieved.'
)
return []
return dict()
logging.debug('Fetching existing messages from Gotify')
return self._call('GET', '/message')['json'].get('messages', [])
def send_alert(self, payload):
def send_alert(self, payload: dict) -> dict:
"""
Method to send a message payload to a Gotify server
"""
logging.debug('Sending message to Gotify')
return self._call('POST', '/message', body=json.dumps(payload, indent=2))
return self._call('POST', '/message', data=payload)
def healthcheck(self):
def healthcheck(self) -> dict:
"""
Method to perform a healthcheck against Gotify
"""

View file

@ -1,6 +1,9 @@
"""
Module for handling any healthcheck related activity
"""
from typing import Tuple
from .gotify import Gotify
class Healthcheck:
@ -8,21 +11,10 @@ class Healthcheck:
Class to handle the healthchecks
"""
def __init__(self, gotify_client):
def __init__(self, gotify_client: Gotify):
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):
def gotify_alive(self) -> Tuple[str, int]:
"""
Simple method to return the Gotify healthcheck response
"""

View file

@ -2,6 +2,9 @@
Module for handling the messaging
"""
import logging
from typing import Optional
from .gotify import Gotify
class MessageHandler:
@ -9,12 +12,17 @@ class MessageHandler:
Class to handle alert messaging
"""
def __init__(self, gotify_client, disable_resolved=False, delete_onresolve=False):
def __init__(
self,
gotify_client: Gotify,
disable_resolved: Optional[bool] = False,
delete_onresolve: Optional[bool] = False,
):
self.gotify = gotify_client
self.disable_resolved = disable_resolved
self.delete_onresolve = delete_onresolve
def process(self, alert):
def process(self, alert: dict) -> dict:
"""
Method to process the alert message
"""
@ -30,11 +38,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,
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,
)

View file

@ -1,80 +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()
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', '')

0
src/conftest.py Normal file
View file

View file

@ -0,0 +1,82 @@
"""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.assertEqual(
self.alertify.healthcheck(),
('OK', 200),
)
@patch('Alertify.health.Healthcheck.gotify_alive')
def test_bad_healthcheck(self, mock_gotify_alive):
"""Test"""
mock_gotify_alive.return_value = {
'status': 408,
'reason': 'Request Timeout',
'json': None,
}
self.assertNotEqual(
self.alertify.healthcheck(),
('OK', 200),
)

View file

@ -0,0 +1,53 @@
"""Test"""
import unittest
from Alertify import config # pylint: disable=import-error
class ConfigTest(unittest.TestCase):
"""
Tests for methods in the Config class.
"""
@classmethod
def setUpClass(cls):
cls.defaults = {
'delete_onresolve': bool(False),
'disable_resolved': bool(False),
'gotify_key_app': str(),
'gotify_key_client': str(),
'gotify_url_prefix': str('http://localhost'),
'listen_port': int(8080),
'verbose': int(0),
}
@classmethod
def tearDownClass(cls):
pass
def setUp(self):
pass
def tearDown(self):
pass
def test_items(self):
"""Test"""
self.assertEqual(
config.Config().items(),
self.defaults.items(),
)
def test_keys(self):
"""Test"""
self.assertListEqual(
config.Config.keys(),
list(self.defaults.keys()),
)
def test_defaults(self):
"""Test"""
self.assertDictEqual(
config.Config.defaults(),
self.defaults,
)

View file

@ -0,0 +1,120 @@
"""
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
class GotifyTest(unittest.TestCase):
"""
Tests for methods in the Gotify class.
"""
@classmethod
def setUpClass(cls):
cls.gotify_client = gotify.Gotify('http://localhost', '')
@classmethod
def tearDownClass(cls):
pass
def setUp(self):
pass
def tearDown(self):
pass
@patch('requests.Session.request')
def test_delete(self, mock_request):
"""Test"""
mock_request.return_value.status_code = 200
mock_request.return_value.reason = 'OK'
mock_request.return_value.content = ''
self.assertDictEqual(
self.gotify_client.delete('123'),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)
@patch('Alertify.gotify.Gotify.messages')
def test_find_byfingerprint(self, mock_messages):
"""Test"""
mock_messages.return_value = [
{
'id': 42,
'extras': {'alertify': {'fingerprint': 'deadbeefcafebabe'}},
}
]
self.assertListEqual(
self.gotify_client.find_byfingerprint({'fingerprint': 'deadbeefcafebabe'}),
[42],
)
def test_messages(self):
"""Test"""
self.assertDictEqual(
self.gotify_client.messages(),
dict(),
)
@patch('requests.Session.request')
def test_send_alert_empty(self, mock_request):
"""Test"""
mock_request.return_value.status_code = 200
mock_request.return_value.reason = 'OK'
mock_request.return_value.content = ''
self.assertDictEqual(
self.gotify_client.send_alert(dict()),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)
@patch('requests.Session.request')
def test_send_alert_dummy(self, mock_request):
"""Test"""
mock_request.return_value.status_code = 200
mock_request.return_value.reason = 'OK'
mock_request.return_value.content = ''
self.assertDictEqual(
self.gotify_client.send_alert(
{
'title': 'TITLE',
'message': 'MESSAGE',
'priority': 0,
'extras': dict(),
}
),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)
@patch('requests.Session.request')
def test_healthcheck(self, mock_request):
"""Test"""
mock_request.return_value.status_code = 200
mock_request.return_value.reason = 'OK'
mock_request.return_value.content = ''
self.assertDictEqual(
self.gotify_client.healthcheck(),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)

View file

@ -0,0 +1,43 @@
"""Test"""
import unittest
from unittest.mock import patch
from Alertify import gotify, health # pylint: disable=import-error
class HealthcheckTest(unittest.TestCase):
"""
Tests for methods in the Healthcheck class.
"""
@classmethod
def setUpClass(cls):
cls.healthcheck = health.Healthcheck(gotify.Gotify('http://localhost', '', ''))
@classmethod
def tearDownClass(cls):
pass
def setUp(self):
pass
def tearDown(self):
pass
@patch('Alertify.health.Healthcheck.gotify_alive')
def test_gotify_alive(self, mock_gotify_alive):
"""Test"""
mock_gotify_alive.return_value = {
'status': 200,
'reason': 'OK',
'json': None,
}
self.assertDictEqual(
self.healthcheck.gotify_alive(),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)

View file

@ -0,0 +1,51 @@
"""Test"""
import unittest
from unittest.mock import patch
from Alertify import gotify, messaging # pylint: disable=import-error
class MessageHandlerTest(unittest.TestCase):
"""
Tests for methods in the MessageHandler class.
"""
@classmethod
def setUpClass(cls):
cls.messaging = messaging.MessageHandler(
gotify.Gotify('http://localhost', '', '')
)
@classmethod
def tearDownClass(cls):
pass
def setUp(self):
pass
def tearDown(self):
pass
@patch('Alertify.gotify.Gotify.send_alert')
def test_process(self, mock_send_alert):
"""Test"""
mock_send_alert.return_value = {
'status': 200,
'reason': 'OK',
'json': None,
}
self.assertDictEqual(
self.messaging.process(
{
'status': 'firing',
'labels': {},
'annotations': {},
}
),
{
'status': 200,
'reason': 'OK',
'json': None,
},
)