Compare commits

...

25 commits
1.4 ... main

Author SHA1 Message Date
Scott Wallace b6fa8e6632 Fix the CLI healthcheck 2021-05-27 07:38:16 +01:00
Scott Wallace fbcd86a67b Fix JSON response from Gotify calls 2021-05-27 07:38:16 +01:00
Scott Wallace a4688c4442 Move to requests to handle TLS connections
Fixes #15
2021-05-27 07:38:16 +01:00
Scott Wallace 4d74be6b9f Add type hints 2021-05-27 07:38:16 +01:00
Scott Wallace 1e0099400d Fix bad return in Gotify method 2021-05-27 07:38:16 +01:00
Scott Wallace d8a82c1334 Simple cosmetic change 2021-05-27 07:38:16 +01:00
Scott Wallace 16030fbe05 Add negative healthcheck test 2021-05-27 07:38:16 +01:00
Scott Wallace e47989d80a Cosmetic fix-up 2021-05-27 07:38:16 +01:00
Scott Wallace 076207118b Fix README 2021-05-27 07:38:16 +01:00
Scott Wallace ed5ba680da Move to Flask to fix #17 2021-05-27 07:38:16 +01:00
Scott Wallace e0a6c37031 Make the log level calculation "safer" 2021-05-27 07:38:16 +01:00
Scott Wallace 825f55e70a Resolves #18: Ensure all matching messages are removed, if enabled 2021-05-27 07:38:16 +01:00
Scott Wallace 6850133ffe Remove testing on Python v3.5 as f-strings are used 2021-05-27 07:38:16 +01:00
Scott Wallace 1672331700 Resolve #8: Add beginnings of a test framework 2021-05-27 07:38:16 +01:00
Scott Wallace 6644a0b20e Rename the variables for the two different types of Gotify key 2021-05-27 07:38:16 +01:00
Scott Wallace 7496d5505e Resolve #16: Verbosity config should be levels not binary
Resolves: #16
2021-05-27 07:38:16 +01:00
Scott Wallace f46f99c1a2 Fix extra "resolved" message 2020-11-03 07:53:05 +00:00
Scott Wallace f36fb69435 Version bump 2020-11-03 07:53:05 +00:00
Scott Wallace 3c9128c233 Resolves #18: Ensure all matching messages are removed, if enabled 2020-11-03 07:53:05 +00:00
Scott Wallace a6aad06dac
Create python-package.yml 2020-10-28 17:37:07 +00:00
Scott Wallace beb470fe4b Perform a small refactor 2020-10-25 09:17:40 +00:00
Scott Wallace 0cb79d734a Removed defaults for instance and description 2020-10-25 09:17:40 +00:00
Scott Wallace a8782e43d3 Add version output.
Resolves: #12
2020-10-25 09:17:40 +00:00
Scott Wallace 07c5cb2f01 Refactor the code into modules 2020-10-25 09:17:40 +00:00
Scott Wallace 440e211723 Format Python code 2020-10-25 09:17:40 +00:00
19 changed files with 821 additions and 310 deletions

39
.github/workflows/python-package.yml vendored Normal file
View file

@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python package
on:
push:
branches: [ main, dev/* ]
pull_request:
branches: [ main, dev/* ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest

3
.gitignore vendored
View file

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

View file

@ -1,4 +1,3 @@
# For more information, please refer to https://aka.ms/vscode-docker-python
FROM python:3.8-slim-buster
# Keeps Python from generating .pyc files in the container
@ -7,21 +6,18 @@ ENV PYTHONDONTWRITEBYTECODE 1
# Turns off buffering for easier container logging
ENV PYTHONUNBUFFERED 1
# Install pip requirements
ADD requirements.txt .
RUN python -m pip install -r requirements.txt
WORKDIR /app
ADD src/alertify.py /app
ADD src/gotify.py /app
COPY alertify.py /app
COPY src /app/src
# Switching to a non-root user, please refer to https://aka.ms/vscode-docker-python-user-rights
RUN useradd appuser && chown -R appuser /app
USER appuser
EXPOSE 8080
# During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug
CMD ["python", "alertify.py"]
HEALTHCHECK --interval=30s --timeout=3s --retries=1 \

View file

@ -13,26 +13,26 @@ 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.
* 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:
| Field | Default value |
|-------------|---------------|
| Description | `[nodata]` |
| Instance | `[unknown]` |
| Priority | `5` |
| Severity | `Warning` |
@ -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:
@ -72,9 +72,9 @@ services:
- "8080:8080"
environment:
- TZ=Europe/London
- GOTIFY_KEY=_APPKEY_
- GOTIFY_CLIENT=_CLIENTKEY_
- GOTIFY_SERVER=gotify
- GOTIFY_PORT=80
- DELETE_ONRESOLVE=true
- GOTIFY_KEY_APP=_APPKEY_
- GOTIFY_KEY_CLIENT=_CLIENTKEY_
- GOTIFY_URL_PREFIX=http://gotify
restart: unless-stopped
```

87
alertify.py Normal file
View file

@ -0,0 +1,87 @@
#!/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() -> argparse.ArgumentParser:
"""
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() -> int:
"""
Main program logic
"""
logging.basicConfig(
format='%(levelname)s: %(message)s',
level=logging.INFO,
)
args = parse_cli()
alertify = Alertify.Alertify()
alertify.configure(args.config)
# -----------------------------
# 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 status == 200
logging.info('Version: %s', Alertify.__version__)
if alertify.config.verbose:
logging.debug('Parsed config:')
for key, val in alertify.config.items():
logging.debug('%s: %s', key, val)
alertify.run()
return 0
sys.exit(main())

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)

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
from typing import Optional
import yaml
class Config:
"""
Class to handle the config
"""
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)
def __init__(self, configfile: Optional[str] = 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) -> 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) -> list:
"""
Method to return the defaults as a list of dict_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) -> dict:
"""
Classmethod to return the defaults as a dictionary
"""
return {key: getattr(cls, key) for key in cls.keys()}

View file

@ -1,9 +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:
@ -11,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 = {
@ -20,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
"""
@ -30,43 +33,46 @@ 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()
except ConnectionRefusedError as error:
logging.error(error)
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 {
'status': error.errno,
'reason': error.strerror
'reason': error.strerror,
}
resp_obj = {
'status': response.status,
'status': response.status_code,
'reason': response.reason,
'json': None
'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(error)
logging.error('Could not parse JSON: %s', error)
logging.debug('Returned from Gotify:\n%s', json.dumps(resp_obj, indent=2))
logging.debug('Status: %s, Reason: %s', resp_obj['status'], resp_obj['reason'])
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
"""
@ -74,33 +80,43 @@ 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():
try:
old_fingerprint = old_message['extras']['alertify']['fingerprint']
if old_fingerprint == new_fingerprint:
return old_message['id']
msg_list.append(old_message['id'])
except KeyError:
logging.debug('No fingerprint found in message ID: %s', old_message['id'])
continue
logging.warning(
'No fingerprint found in message ID: %s',
old_message['id'],
)
logging.debug('No fingerprint matched.')
return None
return msg_list
def messages(self):
def messages(self) -> dict:
"""
Method to return a list of messages from the Gotify server
"""
if not self.client_key:
logging.debug('No client key is configured. No messages could be retrieved.')
return []
logging.warning(
'No client key is configured. No messages could be retrieved.'
)
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) -> dict:
"""
Method to perform a healthcheck against Gotify
"""
return self._call('GET', '/health')

21
src/Alertify/health.py Normal file
View file

@ -0,0 +1,21 @@
"""
Module for handling any healthcheck related activity
"""
from typing import Tuple
from .gotify import Gotify
class Healthcheck:
"""
Class to handle the healthchecks
"""
def __init__(self, gotify_client: Gotify):
self.gotify = gotify_client
def gotify_alive(self) -> Tuple[str, int]:
"""
Simple method to return the Gotify healthcheck response
"""
return self.gotify.healthcheck()

76
src/Alertify/messaging.py Normal file
View file

@ -0,0 +1,76 @@
"""
Module for handling the messaging
"""
import logging
from typing import Optional
from .gotify import Gotify
class MessageHandler:
"""
Class to handle alert messaging
"""
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: dict) -> dict:
"""
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:
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'}
prefix = 'resolved'
else:
prefix = alert['labels'].get('severity', 'warning')
instance = alert['labels'].get('instance', None)
gotify_msg = {
'title': '[{}] {}'.format(
prefix.upper(),
alert['annotations'].get('summary'),
),
'message': '{}{}'.format(
f'{instance}: ' if instance else '',
alert['annotations'].get('description', ''),
),
'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)

View file

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

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,
},
)