Refactor the code into modules

This commit is contained in:
Scott Wallace 2020-10-24 20:43:19 +01:00
parent 440e211723
commit 07c5cb2f01
10 changed files with 385 additions and 267 deletions

View file

@ -12,8 +12,8 @@ ADD requirements.txt .
RUN python -m pip install -r requirements.txt RUN python -m pip install -r requirements.txt
WORKDIR /app WORKDIR /app
ADD src/alertify.py /app COPY alertify.py /app
ADD src/gotify.py /app COPY src /app/src
# Switching to a non-root user, please refer to https://aka.ms/vscode-docker-python-user-rights # Switching to a non-root user, please refer to https://aka.ms/vscode-docker-python-user-rights
RUN useradd appuser && chown -R appuser /app RUN useradd appuser && chown -R appuser /app

View file

@ -28,6 +28,8 @@ The following environment variables will override any config or default:
* Listens on port 8080 by default. * Listens on port 8080 by default.
* Forwards `resolved` alerts, if not disabled. * Forwards `resolved` alerts, if not disabled.
* Resolved alerts delete the original alert, if enabled. * Resolved alerts delete the original alert, if enabled.
* Requires a Gotify app key to send alerts to Gotify
* Requires a Gotify client key to delete original alert on resolution
* Defaults, if not sent: * Defaults, if not sent:
| Field | Default value | | Field | Default value |
|-------------|---------------| |-------------|---------------|
@ -72,6 +74,7 @@ services:
- "8080:8080" - "8080:8080"
environment: environment:
- TZ=Europe/London - TZ=Europe/London
- DELETE_ONRESOLVE=true
- GOTIFY_KEY=_APPKEY_ - GOTIFY_KEY=_APPKEY_
- GOTIFY_CLIENT=_CLIENTKEY_ - GOTIFY_CLIENT=_CLIENTKEY_
- GOTIFY_SERVER=gotify - GOTIFY_SERVER=gotify

79
alertify.py Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/python3
"""
Main entrypoint to run Alertify
"""
import argparse
import logging
import os
import sys
from src import alertify
if __name__ == '__main__':
def parse_cli():
"""
Function to parse the CLI
"""
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()
]
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description='Bridge between Prometheus Alertmanager and Gotify\n',
epilog='The following environment variables will override any config or default:\n'
+ '\n'.join(defaults),
)
parser.add_argument(
'-c',
'--config',
default=f'{os.path.splitext(__file__)[0]}.yaml',
help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)',
)
parser.add_argument(
'-H',
'--healthcheck',
action='store_true',
help='simply exit with 0 for healthy or 1 when unhealthy',
)
return parser.parse_args()
def main():
"""
main()
"""
logging.basicConfig(
format='%(levelname)s: %(message)s',
level=logging.INFO,
)
args = parse_cli()
# forwarder = alertify.Alertify(args.config)
forwarder = alertify.Alertify()
if forwarder.config.verbose:
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
if args.healthcheck:
# Invert the sense of 'healthy' for Unix CLI usage
return not forwarder.healthcheck.report()
if forwarder.config.verbose:
logging.debug('Parsed config:')
for key, val in forwarder.config.items():
logging.debug('%s: %s', key, val)
forwarder.server.listen_and_run()
return 0
sys.exit(main())

View file

@ -1,264 +0,0 @@
#!/usr/bin/env python3
"""
Module to act as a bridge between Prometheus Alertmanager and Gotify
"""
import argparse
import functools
import json
import logging
import os
import sys
from distutils.util import strtobool
from http.server import HTTPServer, SimpleHTTPRequestHandler
import yaml
import gotify
DEFAULTS = {
'delete_onresolve': bool(False),
'disable_resolved': bool(False),
'gotify_client': str(),
'gotify_key': str(),
'gotify_port': int(80),
'gotify_server': str('localhost'),
'listen_port': int(8080),
'verbose': bool(False),
}
class HTTPHandler(SimpleHTTPRequestHandler):
"""
Class to handle the HTTP requests from a client
"""
config = None
def _alerts(self):
"""
Method to handle the request for alerts
"""
if not healthy(self.config):
logging.error('Check requirements')
self._respond(500, 'Server not configured correctly')
return
content_length = int(self.headers['Content-Length'])
rawdata = self.rfile.read(content_length)
try:
am_msg = json.loads(rawdata.decode())
except json.decoder.JSONDecodeError as error:
logging.error('Bad JSON: %s', error)
self._respond(400, f'Bad JSON: {error}')
return
logging.debug('Received from Alertmanager:\n%s', json.dumps(am_msg, indent=2))
gotify_client = gotify.Gotify(
self.config.get('gotify_server'),
self.config.get('gotify_port'),
self.config.get('gotify_key'),
self.config.get('gotify_client'),
)
for alert in am_msg['alerts']:
try:
if alert['status'] == 'resolved':
if self.config.get('disable_resolved'):
logging.info('Ignoring resolved messages')
self._respond(
200,
'Ignored. "resolved" messages are disabled',
)
continue
if self.config.get('delete_onresolve'):
alert_id = gotify_client.find_byfingerprint(alert)
if alert_id:
response = gotify_client.delete(alert_id)
continue
logging.debug('Could not find a matching message to delete.')
prefix = 'Resolved'
else:
prefix = alert['labels'].get('severity', 'warning').capitalize()
gotify_msg = {
'title': '{}: {}'.format(
prefix,
alert['annotations'].get('summary'),
),
'message': '{}: {}'.format(
alert['labels'].get('instance', '[unknown]'),
alert['annotations'].get('description', '[nodata]'),
),
'priority': int(alert['labels'].get('priority', 5)),
'extras': {
'alertify': {
'fingerprint': alert.get('fingerprint', None),
}
},
}
except KeyError as error:
logging.error('KeyError: %s', error)
self._respond(400, f'Missing field: {error}')
return
response = gotify_client.send_alert(gotify_msg)
try:
self._respond(response['status'], response['reason'])
except UnboundLocalError:
self._respond('204', '')
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()))
# Override built-in method
def do_GET(self): # pylint: disable=invalid-name
"""
Method to handle GET requests
"""
if self.path == '/healthcheck':
if not healthy(self.config):
logging.error('Check requirements')
self._respond(500, 'ERR')
self._respond(200, 'OK')
# Override built-in method
def do_POST(self): # pylint: disable=invalid-name
"""
Method to handle POST requests from AlertManager
"""
if self.path == '/alert':
self._alerts()
# FIXME: This isn't right. A normal method doesn't work, however.
@classmethod
def set_config(cls, config):
"""
Classmethod to add config to the class
"""
cls.config = config
def healthy(config):
"""
Simple function to return if all the requirements are met
"""
return all(
[
len(config.get('gotify_key', '')),
]
)
@functools.lru_cache
def parse_config(configfile):
"""
Function to parse a configuration file
"""
config = {}
try:
with open(configfile, 'r') as file:
parsed = yaml.safe_load(file.read())
except FileNotFoundError as error:
logging.warning('No config file found (%s)', error.filename)
parsed = {}
# Iterate over the DEFAULTS dictionary and check for environment variables
# of the same name, then check for any items in the YAML config, otherwise
# use the default values.
# Ensure the types are adhered to.
for key, val in DEFAULTS.items():
config[key] = os.environ.get(key.upper(), parsed.get(key, val))
if isinstance(val, bool):
config[key] = strtobool(str(config[key]))
else:
config[key] = type(val)(config[key])
return config
if __name__ == '__main__':
def parse_cli():
"""
Function to parse the CLI
"""
maxlen = max([len(key) for key in DEFAULTS])
defaults = [
f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})'
for key, val in DEFAULTS.items()
]
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description='Bridge between Prometheus Alertmanager and Gotify\n',
epilog='The following environment variables will override any config or default:\n'
+ '\n'.join(defaults),
)
parser.add_argument(
'-c',
'--config',
default=f'{os.path.splitext(__file__)[0]}.yaml',
help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)',
)
parser.add_argument(
'-H',
'--healthcheck',
action='store_true',
help='simply exit with 0 for healthy or 1 when unhealthy',
)
return parser.parse_args()
def main():
"""
main()
"""
logging.basicConfig(
format='%(levelname)s: %(message)s',
level=logging.INFO,
)
args = parse_cli()
config = parse_config(args.config)
if config.get('verbose'):
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
if args.healthcheck:
# Invert the sense of 'healthy' for Unix CLI usage
return not healthy(config)
listen_port = config.get('listen_port')
logging.debug(
'Config:\n%s',
yaml.dump(config, explicit_start=True, default_flow_style=False),
)
logging.info('Starting web server on port %d', listen_port)
try:
with HTTPServer(('', listen_port), HTTPHandler) as webserver:
HTTPHandler.set_config(config)
webserver.serve_forever()
except KeyboardInterrupt:
logging.info('Exiting')
return 0
sys.exit(main())

41
src/alertify/__init__.py Normal file
View file

@ -0,0 +1,41 @@
"""
Alertify module to act as a bridge between Prometheus Alertmanager and Gotify
"""
__author__ = 'Scott Wallace'
__email__ = 'scott@wallace.sh'
__maintainer__ = 'Scott Wallace'
__version__ = '1.5'
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,
)

81
src/alertify/config.py Normal file
View file

@ -0,0 +1,81 @@
"""
Module to handle Alertify's configuration
"""
import inspect
import logging
import os
from distutils.util import strtobool
import yaml
class Config:
"""
Class to handle the config
"""
delete_onresolve = bool(False)
disable_resolved = bool(False)
gotify_client = str()
gotify_key = str()
gotify_port = int(80)
gotify_server = str('localhost')
listen_port = int(8080)
verbose = bool(False)
def __init__(self, configfile=None):
"""
Method to parse a configuration file
"""
logging.debug('Parsing config')
parsed = {}
try:
with open(configfile, 'r') as file:
parsed = yaml.safe_load(file.read())
except FileNotFoundError as error:
logging.warning('No config file found (%s)', error.filename)
except TypeError:
logging.warning('No config file provided.')
# Iterate over the config defaults and check for environment variable
# overrides, then check for any items in the config, otherwise
# use the default values.
for key, default_val in self.defaults().items():
userval = os.environ.get(key.upper(), parsed.get(key, default_val))
# Ensure the types are adhered to.
if isinstance(default_val, bool):
setattr(self, key, strtobool(str(userval)))
else:
setattr(self, key, type(default_val)(userval))
def items(self):
"""
Method to return an iterator for the configured items
"""
return {key: getattr(self, key) for key in self.__dict__}.items()
@classmethod
def keys(cls):
"""
Method to return the defaults as a list of keys
"""
return [
attr[0]
for attr in inspect.getmembers(cls)
if not attr[0].startswith('_')
and not any(
[
inspect.ismethod(attr[1]),
callable(attr[1]),
]
)
]
@classmethod
def defaults(cls):
"""
Classmethod to return the defaults as a dictionary
"""
return {key: getattr(cls, key) for key in cls.keys()}

View file

@ -96,7 +96,7 @@ class Gotify:
Method to return a list of messages from the Gotify server Method to return a list of messages from the Gotify server
""" """
if not self.client_key: if not self.client_key:
logging.debug( logging.warning(
'No client key is configured. No messages could be retrieved.' 'No client key is configured. No messages could be retrieved.'
) )
return [] return []
@ -109,3 +109,9 @@ class Gotify:
""" """
logging.debug('Sending message to Gotify') logging.debug('Sending message to Gotify')
return self._call('POST', '/message', body=json.dumps(payload, indent=2)) return self._call('POST', '/message', body=json.dumps(payload, indent=2))
def healthcheck(self):
"""
Method to perform a healthcheck against Gotify
"""
return self._call('GET', '/health')

View file

@ -0,0 +1,29 @@
"""
Module for handling any healthcheck related activity
"""
class Healthcheck:
"""
Class to handle the healthchecks
"""
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.healthcheck(),
]
)
def gotify_alive(self):
"""
Simple method to return the Gotify healthcheck response
"""
return self.gotify.healthcheck()

63
src/alertify/messaging.py Normal file
View file

@ -0,0 +1,63 @@
"""
Module for handling the messaging
"""
import logging
class MessageHandler:
"""
Class to handle alert messaging
"""
def __init__(self, gotify_client, disable_resolved=False, delete_onresolve=False):
self.gotify = gotify_client
self.disable_resolved = disable_resolved
self.delete_onresolve = delete_onresolve
def process(self, alert):
"""
Method to process the alert message
"""
try:
if alert['status'] == 'resolved':
if self.disable_resolved:
logging.info('Ignoring resolved messages')
return {
'status': 200,
'reason': 'Ignored. "resolved" messages are disabled',
}
if self.delete_onresolve:
alert_id = self.gotify.find_byfingerprint(alert)
if alert_id:
return self.gotify.delete(alert_id)
logging.warning('Could not find a matching message to delete.')
prefix = 'resolved'
else:
prefix = alert['labels'].get('severity', 'warning')
gotify_msg = {
'title': '[{}] {}'.format(
prefix.upper(),
alert['annotations'].get('summary'),
),
'message': '{}: {}'.format(
alert['labels'].get('instance', '[unknown]'),
alert['annotations'].get('description', '[nodata]'),
),
'priority': int(alert['labels'].get('priority', 5)),
'extras': {
'alertify': {
'fingerprint': alert.get('fingerprint', None),
}
},
}
except KeyError as error:
logging.error('KeyError: %s', error)
return {
'status': 400,
'reason': f'Missing field: {error}',
}
return self.gotify.send_alert(gotify_msg)

80
src/alertify/server.py Normal file
View file

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