Refactor the code into modules
This commit is contained in:
parent
440e211723
commit
07c5cb2f01
|
@ -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
|
||||||
|
|
|
@ -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
79
alertify.py
Normal 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())
|
264
src/alertify.py
264
src/alertify.py
|
@ -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
41
src/alertify/__init__.py
Normal 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
81
src/alertify/config.py
Normal 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()}
|
|
@ -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')
|
29
src/alertify/healthcheck.py
Normal file
29
src/alertify/healthcheck.py
Normal 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
63
src/alertify/messaging.py
Normal 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
80
src/alertify/server.py
Normal 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', '')
|
Loading…
Reference in a new issue