alertify/alertify.py

259 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""
Module to act as a bridge between Prometheus Alertmanager and Gotify
"""
import argparse
import functools
import http.client
import json
import os
import sys
from distutils.util import strtobool
from http.server import HTTPServer, SimpleHTTPRequestHandler
import yaml
DEFAULTS = {
'disable_resolved': bool(False),
'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
@staticmethod
def set_config(config):
"""
Method
"""
HTTPHandler.config = config
# Override built-in method
# pylint: disable=invalid-name
def do_GET(self):
"""
Method to handle GET requests
"""
if self.path == '/healthcheck':
if not healthy(self.config):
print('ERROR: Check requirements')
self._respond(500, 'ERR')
self._respond(200, 'OK')
# Override built-in method
# pylint: disable=invalid-name
def do_POST(self):
"""
Method to handle POST requests from AlertManager
"""
if self.path == '/alert':
self._alerts()
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 _alerts(self):
"""
Method to handle the request for alerts
"""
if not healthy(self.config):
print('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:
alert = json.loads(rawdata.decode())
except json.decoder.JSONDecodeError as error:
print(f'ERROR: Bad JSON: {error}')
self._respond(400, f'Bad JSON: {error}')
return
if self.config.get('verbose'):
print('Received from Alertmanager:')
print(json.dumps(alert, indent=2))
try:
if alert['status'] == 'resolved':
if self.config.get('disable_resolved'):
print('Ignoring resolved messages')
self._respond(200, 'Ignored. "resolved" messages are disabled')
return
prefix = 'Resolved'
else:
prefix = alert['commonLabels'].get(
'severity', 'default').capitalize()
gotify_msg = {
'title': '{}: {}'.format(
alert['receiver'],
alert['commonLabels'].get('instance', 'Unknown')
),
'message': '{}: {}'.format(
prefix,
alert['commonAnnotations'].get('description', '...')
),
'priority': int(alert['commonLabels'].get('priority', 5))
}
except KeyError as error:
print(f'ERROR: KeyError: {error}')
self._respond(400, f'Missing field: {error}')
return
if self.config.get('verbose'):
print('Sending to Gotify:')
print(json.dumps(gotify_msg, indent=2))
response = gotify_send(
self.config.get('gotify_server'),
self.config.get('gotify_port'),
self.config.get('gotify_key'),
gotify_msg
)
if self.config.get('verbose'):
print('Status: {status}, Reason: {reason}'.format(**response))
self._respond(response['status'], response['reason'])
def gotify_send(server, port, authkey, payload):
"""
Function to POST data to a Gotify server
"""
gotify = http.client.HTTPConnection(server, port)
headers = {
'X-Gotify-Key': authkey,
'Content-type': 'application/json',
}
try:
gotify.request('POST', '/message', json.dumps(payload), headers)
response = gotify.getresponse()
except ConnectionRefusedError as error:
print(f'ERROR: {error}')
return {
'status': error.errno,
'reason': error.strerror
}
return {
'status': response.status,
'reason': response.reason
}
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:
print(f'WARN: {error}')
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])
if config['verbose']:
print(f'Config:\n{yaml.dump(config, explicit_start=True, default_flow_style=False)}')
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()
"""
args = parse_cli()
config = parse_config(args.config)
if args.healthcheck:
# Invert the sense of 'healthy' for Unix CLI usage
return not healthy(config)
listen_port = config.get('listen_port')
print(f'Starting web server on port {listen_port}')
try:
with HTTPServer(('', listen_port), HTTPHandler) as webserver:
HTTPHandler.set_config(config)
webserver.serve_forever()
except KeyboardInterrupt:
print('Exiting')
return 0
sys.exit(main())