Compare commits

...

33 commits
1.1 ... 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
Scott Wallace 8e71192d0f Use logging to handle console messaging 2020-10-23 14:50:03 +01:00
Scott Wallace 622cb7a635 Fix README.md 2020-10-23 14:50:03 +01:00
Scott Wallace c10920c89e Split out gotify class to its own module 2020-10-23 14:50:03 +01:00
Scott Wallace 71494cdc0e Fix message contents 2020-10-23 14:50:03 +01:00
Scott Wallace 12b39b1c08 Add the ability to delete original alert when matching resolved message arrives 2020-10-23 14:50:03 +01:00
Scott Wallace 3c57c113cd Add the ability to disabled resolved messages
Resolves #5
2020-10-20 17:55:12 +01:00
Scott Wallace 3dff3c9121
Fix comments 2020-10-20 15:39:55 +01:00
Scott Wallace c05d02a16f Add defaults and config parser.
Resolves #4
Resolves #6
2020-10-20 15:35:24 +01:00
19 changed files with 936 additions and 198 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

2
.gitignore vendored
View file

@ -1 +1,3 @@
__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,20 +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 alertify.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

@ -2,30 +2,39 @@ This application bridges [Prometheus Alertmanager](https://prometheus.io/docs/al
# Usage
```
usage: alertify.py [-h] [-H]
usage: alertify.py [-h] [-c CONFIG] [-H]
Bridge between Prometheus Alertmanager and Gotify
optional arguments:
-h, --help show this help message and exit
-H, --healthcheck Simply exit with 0 for healthy or 1 when unhealthy
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path to config YAML. (default: alertify.yaml)
-H, --healthcheck simply exit with 0 for healthy or 1 when unhealthy
Three environment variables are required to be set:
* GOTIFY_SERVER: hostname of the Gotify server
* GOTIFY_PORT: port of the Gotify server
* GOTIFY_KEY: app token for alertify
The following environment variables will override any config or default:
* 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 sent.
* 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 |
|-------------|---------------|
| Priority | `5` |
| Description | `...` |
| Severity | `Default` |
| Severity | `Warning` |
# Docker
@ -38,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=XXXXXXXX -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:
@ -63,8 +72,9 @@ services:
- "8080:8080"
environment:
- TZ=Europe/London
- GOTIFY_KEY=XXXXXXXXXXXX
- 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
```

View file

@ -1,184 +1,86 @@
#!/usr/bin/env python3
#!/usr/bin/python3
"""
Module to act as a Prometheus Exporter for Docker containers with a
healthcheck configured
Main entrypoint to run Alertify
"""
import argparse
import http.client
import json
import logging
import os
import sys
from http.server import HTTPServer, SimpleHTTPRequestHandler
LISTEN_PORT = 8080
VERBOSE = int(os.environ.get('ALERTIFY_VERBOSE', 0))
class HTTPHandler(SimpleHTTPRequestHandler):
"""
Class to encompass the requirements of a Prometheus Exporter
for Docker containers with a healthcheck configured
"""
# Override built-in method
# pylint: disable=invalid-name
def do_GET(self):
"""
Method to handle GET requests
"""
if self.path == '/healthcheck':
if not healthy():
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):
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():
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 VERBOSE:
print('Received from Alertmanager:')
print(json.dumps(alert, indent=2))
try:
if alert['status'] == 'resolved':
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 VERBOSE:
print('Sending to Gotify:')
print(json.dumps(gotify_msg, indent=2))
response = 'Status: {status}, Reason: {reason}'.format(
**gotify_send(
os.environ['GOTIFY_SERVER'],
os.environ['GOTIFY_PORT'],
os.environ['GOTIFY_KEY'],
gotify_msg
)
)
if VERBOSE:
print(response)
self._respond(200, response)
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',
}
gotify.request('POST', '/message', json.dumps(payload), headers)
response = gotify.getresponse()
return {
'status': response.status,
'reason': response.reason
}
def healthy():
"""
Simple funtion to return if all the requirements are met
"""
return all([
'GOTIFY_SERVER' in os.environ,
'GOTIFY_PORT' in os.environ,
'GOTIFY_KEY' in os.environ,
])
from src import Alertify
if __name__ == '__main__':
def cli_parse():
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='Three environment variables are required to be set:\n'
' * GOTIFY_SERVER: hostname of the Gotify server\n'
' * GOTIFY_PORT: port of the Gotify server\n'
' * GOTIFY_KEY: app token for alertify'
epilog='The following environment variables will override any config or default:\n'
+ '\n'.join(defaults),
)
parser.add_argument(
'-H', '--healthcheck',
'-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',
help='simply exit with 0 for healthy or 1 when unhealthy',
)
return parser.parse_args()
def main():
def main() -> int:
"""
main()
Main program logic
"""
args = cli_parse()
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 healthy()
return not status == 200
print(f'Starting web server on port {LISTEN_PORT}')
try:
HTTPServer(('', LISTEN_PORT), HTTPHandler).serve_forever()
except KeyboardInterrupt:
print('Exiting')
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

6
example.yaml Normal file
View file

@ -0,0 +1,6 @@
---
disable_resolved: false
gotify_key: sOmEsEcReTkEy1
gotify_url_prefix: http://gotifyserver.example.net
listen_port: "8080"
verbose: true

View file

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

122
src/Alertify/gotify.py Normal file
View file

@ -0,0 +1,122 @@
"""
Module to handle communication with the Gotify server
"""
import json
import logging
import socket
from typing import Optional
from requests import request
class Gotify:
"""
Class to handle Gotify communications
"""
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 = {
'Content-type': 'application/json',
'Accept': 'application/json',
}
def _call(self, method: str, url: str, data: Optional[object] = None) -> dict:
"""
Method to call Gotify with an app or client key as appropriate
"""
headers = self.base_headers.copy()
if method in ['GET', 'DELETE']:
headers['X-Gotify-Key'] = self.client_key
else:
headers['X-Gotify-Key'] = self.app_key
logging.debug('Sending to Gotify:\n%s', data)
try:
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,
}
resp_obj = {
'status': response.status_code,
'reason': response.reason,
'json': None,
}
if len(response.content) > 0:
try:
resp_obj['json'] = json.loads(response.content.decode())
except json.decoder.JSONDecodeError as 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: 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: str) -> list:
"""
Method to return the ID of a matching message
"""
try:
new_fingerprint = message['fingerprint']
except KeyError:
logging.debug('No fingerprint found in new message')
return list()
msg_list = []
for old_message in self.messages():
try:
old_fingerprint = old_message['extras']['alertify']['fingerprint']
if old_fingerprint == new_fingerprint:
msg_list.append(old_message['id'])
except KeyError:
logging.warning(
'No fingerprint found in message ID: %s',
old_message['id'],
)
return msg_list
def messages(self) -> dict:
"""
Method to return a list of messages from the Gotify server
"""
if not self.client_key:
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: dict) -> dict:
"""
Method to send a message payload to a Gotify server
"""
logging.debug('Sending message to Gotify')
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)

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

82
test.sh
View file

@ -2,56 +2,86 @@
SERVER=${1:-'localhost:8080'}
name=testAlert-$RANDOM
NAME=testAlert-$RANDOM
FINGERPRINT=$(date +%s | md5sum | cut -f1 -d' ')
URL="http://${SERVER}/alert"
bold=$(tput bold)
normal=$(tput sgr0)
BOLD=$(tput bold)
NORMAL=$(tput sgr0)
call_alertmanager() {
curl -v "${URL}" --header 'Content-type: application/json' --data @<(cat <<EOF
VALUE=${1}
curl -v "${URL}" --header 'Expect:' --header 'Content-type: application/json' --data @<(cat <<EOF
{
"version": "4",
"groupKey": "testGroup",
"truncatedAlerts": 0,
"status": "${STATUS}",
"receiver": "alertify",
"commonLabels": {
"alertname": "${name}",
"service": "testService",
"severity":"warning",
"instance": "server.example.net",
"namespace": "testNamespace",
"label_costcentre": "testCostCentre"
},
"commonAnnotations": {
"summary": "Testing latency is high!",
"description": "Testing latency is at ${1}"
},
"status": "${STATUS}",
"alerts": [
{
"status": "${STATUS}",
"generatorURL": "http://alertmanager.example.net/$name",
"labels": {
"alertname": "${NAME}",
"id": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b",
"instance": "localhost:1234",
"job": "test_job",
"name": "testserver",
"priority": "1",
"severity": "low",
"value": "${VALUE}"
},
"annotations": {
"description": "testserver: unhealthy",
"summary": "Server unhealthy"
},
"startsAt": "${START}",
"endsAt": "${END}"
"endsAt": "${END}",
"generatorURL": "http://example.com/some/url",
"fingerprint": "${FINGERPRINT}"
}
]
],
"groupLabels": {
"alertname": "${NAME}",
"id": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b",
"instance": "localhost:1234",
"job": "test_job",
"name": "testserver",
"priority": "1",
"severity": "low",
"value": "${VALUE}"
},
"commonLabels": {
"alertname": "${NAME}",
"id": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b",
"instance": "localhost:1234",
"job": "test_job",
"name": "testserver",
"priority": "1",
"severity": "low",
"value": "${VALUE}"
},
"commonAnnotations": {
"description": "testserver: unhealthy",
"summary": "Server unhealthy"
},
"externalURL": "http://1ff297bc31a0:9093",
"version": "4",
"groupKey": "{}:{alertname=\"${NAME}\", id=\"01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b\", instance=\"localhost:1234\", job=\"test_job\", name=\"testserver\", priority=\"1\", severity=\"low\", value=\"${VALUE}\"}",
"truncatedAlerts": 0
}
EOF
)
}
echo "${bold}Firing alert ${name} ${normal}"
echo "${BOLD}Firing alert ${NAME} ${NORMAL}"
STATUS='firing'
START=$(date --rfc-3339=seconds | sed 's/ /T/')
END="0001-01-01T00:00:00Z"
call_alertmanager 42
echo -e "\n"
echo "${bold}Press enter to resolve alert ${name} ${normal}"
echo "${BOLD}Press enter to resolve alert ${NAME} ${NORMAL}"
read -r
echo "${bold}Sending resolved ${normal}"
echo "${BOLD}Sending resolved ${NORMAL}"
STATUS='resolved'
END=$(date --rfc-3339=seconds | sed 's/ /T/')
call_alertmanager 0