├── tests ├── __init__.py ├── test_scheduler.py ├── test_consumers.py └── test_config.py ├── debian ├── compat ├── source │ └── format ├── copyright ├── rules ├── sauna.service ├── sauna.postrm ├── sauna.postinst ├── control ├── sauna.init └── changelog ├── sauna ├── commands │ ├── ext │ │ ├── __init__.py │ │ ├── status.py │ │ └── list.py │ └── __init__.py ├── consumers │ ├── ext │ │ ├── __init__.py │ │ ├── http_server.py │ │ ├── stdout.py │ │ ├── http_server │ │ │ ├── html.py │ │ │ ├── dashboard_html.py │ │ │ └── __init__.py │ │ ├── http.py │ │ ├── http_icinga.py │ │ ├── home_assistant_mqtt.py │ │ ├── tcp_server.py │ │ └── nsca.py │ ├── __init__.py │ └── base.py ├── plugins │ ├── ext │ │ ├── __init__.py │ │ ├── dummy.py │ │ ├── tcp.py │ │ ├── command.py │ │ ├── simple_domain.py │ │ ├── load.py │ │ ├── mdstat.py │ │ ├── puppet_agent.py │ │ ├── ntpd.py │ │ ├── disk.py │ │ ├── memory.py │ │ ├── apt.py │ │ ├── redis.py │ │ ├── disque.py │ │ ├── http.py │ │ ├── network.py │ │ ├── postfix.py │ │ ├── hwmon.py │ │ ├── http_json.py │ │ ├── memcached.py │ │ ├── processes.py │ │ └── supervisor.py │ ├── __init__.py │ └── base.py ├── main.py └── scheduler.py ├── readthedocs.yml ├── .gitignore ├── .editorconfig ├── doc ├── user │ ├── faq.rst │ ├── consumers.rst │ ├── plugins.rst │ ├── cli.rst │ ├── install.rst │ ├── service.rst │ ├── cookbook.rst │ └── configuration.rst ├── index.rst ├── dev │ ├── contributing.rst │ ├── internals.rst │ └── custom.rst ├── Makefile └── conf.py ├── Makefile ├── Dockerfile_deb ├── Dockerfile ├── .github └── workflows │ ├── python-publish.yml │ └── tests.yml ├── docker-entrypoint.sh ├── LICENSE ├── README.rst └── setup.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /sauna/commands/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sauna/consumers/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sauna/plugins/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http_server.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Nicolas Le Manchet 2 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | python: 3 | version: 3 4 | setup_py_install: true 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | sauna.egg-info 4 | venv 5 | sauna.yml 6 | sauna-sample.yml 7 | doc/_build 8 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | export DH_VERBOSE=1 3 | export PYBUILD_NAME=sauna 4 | 5 | %: 6 | dh $@ ---with systemd -with=python3 --buildsystem=pybuild 7 | -------------------------------------------------------------------------------- /debian/sauna.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Sauna health check daemon 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/usr/bin/sauna --config /etc/sauna.yml 9 | User=sauna 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*.{py,rst}] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | max_line_length = 79 13 | 14 | [*.rst] 15 | max_line_length = 99 16 | -------------------------------------------------------------------------------- /debian/sauna.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | 5 | delete_base_config_file () { 6 | 7 | if [ -f /etc/sauna.yml ]; then 8 | rm /etc/sauna.yml 9 | fi 10 | 11 | } 12 | 13 | 14 | case "$1" in 15 | 16 | purge) 17 | delete_base_config_file ;; 18 | 19 | esac 20 | 21 | 22 | #DEBHELPER# 23 | 24 | exit 0 25 | -------------------------------------------------------------------------------- /doc/user/faq.rst: -------------------------------------------------------------------------------- 1 | .. _faq: 2 | 3 | Frequently Asked Questions 4 | ========================== 5 | 6 | What is the licence? 7 | -------------------- 8 | 9 | Sauna is released under the :download:`BSD license <../../LICENSE>`. 10 | 11 | Where does that name come from? 12 | ------------------------------- 13 | 14 | With more than 10M repositories on GitHub, do you know how hard is it to find a cool name for your 15 | new project? 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | publish: 2 | pip install -U setuptools wheel twine 3 | python setup.py sdist 4 | python setup.py bdist_wheel 5 | twine upload dist/* 6 | rm -fr build dist sauna.egg-info 7 | 8 | deb: 9 | docker build -t sauna-deb-package -f Dockerfile_deb . 10 | docker run --rm -v /tmp/sauna/:/output sauna-deb-package 11 | @echo "Debian package available in /tmp/sauna" 12 | 13 | clean: 14 | rm -fr build dist sauna.egg-info 15 | 16 | -------------------------------------------------------------------------------- /sauna/consumers/ext/stdout.py: -------------------------------------------------------------------------------- 1 | from sauna.consumers.base import QueuedConsumer 2 | from sauna.consumers import ConsumerRegister 3 | 4 | my_consumer = ConsumerRegister('Stdout') 5 | 6 | 7 | @my_consumer.consumer() 8 | class StdoutConsumer(QueuedConsumer): 9 | 10 | def _send(self, service_check): 11 | print(service_check) 12 | 13 | @staticmethod 14 | def config_sample(): 15 | return ''' 16 | # Just prints checks on the standard output 17 | - type: Stdout 18 | ''' 19 | -------------------------------------------------------------------------------- /sauna/commands/__init__.py: -------------------------------------------------------------------------------- 1 | class CommandRegister: 2 | all_commands = {} 3 | 4 | def command(self, **options): 5 | def decorator(func): 6 | command_name = options.pop('name', func.__name__) 7 | self.all_commands[command_name] = func 8 | return func 9 | return decorator 10 | 11 | @classmethod 12 | def get_command(cls, command_name): 13 | try: 14 | return cls.all_commands[command_name] 15 | except KeyError: 16 | return None 17 | -------------------------------------------------------------------------------- /doc/user/consumers.rst: -------------------------------------------------------------------------------- 1 | .. _consumers: 2 | 3 | Consumers 4 | ========= 5 | 6 | Consumers provide a way for sauna to process the checks generated by :ref:`plugins `. The 7 | most common job for a consumer is to send the results to a monitoring server, but it can go beyond 8 | that. 9 | 10 | Consumers are enabled in :ref:`sauna.yml ` 11 | 12 | .. todo:: Find a way to automatically document core consumers. For now you can find the list of 13 | `consumers on GitHub 14 | `_. 15 | -------------------------------------------------------------------------------- /sauna/commands/ext/status.py: -------------------------------------------------------------------------------- 1 | from sauna.commands import CommandRegister 2 | 3 | status_commands = CommandRegister() 4 | 5 | 6 | @status_commands.command(name='status') 7 | def list_active_checks(sauna_instance, args): 8 | """Show the result of active checks.""" 9 | human_status = { 10 | 0: 'OK', 11 | 1: 'Warning', 12 | 2: 'Critical', 13 | 3: 'Unknown', 14 | } 15 | for check in sorted(sauna_instance.launch_all_checks()): 16 | print(' {:<30} {:^14} {}'.format( 17 | check.name, human_status[check.status], check.output 18 | )) 19 | -------------------------------------------------------------------------------- /doc/user/plugins.rst: -------------------------------------------------------------------------------- 1 | .. _plugins: 2 | 3 | Plugins 4 | ======= 5 | 6 | Plugins contains checks that sauna can run to determine the health of the system. Sauna will 7 | periodically run the checks contained in active plugins and forward the results to active 8 | :ref:`consumers `. 9 | 10 | Plugins can either be core ones shipped with sauna, or :ref:`your own plugins ` for 11 | monitoring the specific parts of your system. 12 | 13 | .. todo:: Find a way to automatically document core plugins. For now you can find the list of core 14 | `plugins on GitHub `_. 15 | -------------------------------------------------------------------------------- /sauna/consumers/__init__.py: -------------------------------------------------------------------------------- 1 | class ConsumerRegister: 2 | all_consumers = {} 3 | 4 | def __init__(self, name): 5 | self.name = name 6 | self.consumer_class = None 7 | 8 | def consumer(self): 9 | def decorator(plugin_cls): 10 | self.consumer_class = plugin_cls 11 | self.all_consumers[self.name] = { 12 | 'consumer_cls': self.consumer_class 13 | } 14 | return plugin_cls 15 | return decorator 16 | 17 | @classmethod 18 | def get_consumer(cls, name): 19 | try: 20 | return cls.all_consumers[name] 21 | except KeyError: 22 | return None 23 | -------------------------------------------------------------------------------- /Dockerfile_deb: -------------------------------------------------------------------------------- 1 | FROM debian:stable 2 | 3 | MAINTAINER Nicolas Le Manchet 4 | 5 | # Build a deb package for sauna 6 | # Heavily inspired by 7 | # https://www.spkdev.net/2015/03/03/quickly-build-a-debian-package-with-docker.html 8 | 9 | ENV DEBIAN_FRONTEND noninteractive 10 | ENV DEBIAN_PRIORITY critical 11 | ENV DEBCONF_NOWARNINGS yes 12 | 13 | RUN apt-get update && apt-get -y upgrade 14 | RUN apt-get -y --no-install-recommends install devscripts equivs 15 | 16 | WORKDIR /root 17 | ADD . /root 18 | RUN mk-build-deps -t "apt-get -y --no-install-recommends" -i "debian/control" 19 | RUN dpkg-buildpackage -b 20 | 21 | VOLUME /output 22 | 23 | CMD cp ../*.deb /output 24 | -------------------------------------------------------------------------------- /sauna/plugins/ext/dummy.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, PluginRegister 2 | 3 | my_plugin = PluginRegister('Dummy') 4 | 5 | 6 | @my_plugin.plugin() 7 | class Dummy(Plugin): 8 | 9 | @my_plugin.check() 10 | def dummy(self, check_config): 11 | return (check_config.get('status', 0), 12 | check_config.get('output', 'OK')) 13 | 14 | @staticmethod 15 | def config_sample(): 16 | return ''' 17 | # Fake checks that return the provided 18 | # status and output 19 | - type: Dummy 20 | checks: 21 | - type: dummy 22 | status: 0 23 | output: Everything is alright 24 | ''' 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:edge 2 | 3 | MAINTAINER Nicolas Le Manchet 4 | 5 | RUN set -x \ 6 | && addgroup -S sauna \ 7 | && adduser -u 4343 -D -S -h /app -G sauna sauna \ 8 | && apk update \ 9 | && apk add python3 py3-pip py3-wheel py3-psutil py3-yaml py3-docopt py3-requests py3-redis \ 10 | && pip install pymdstat jsonpath-rw 11 | 12 | WORKDIR /app 13 | 14 | COPY setup.py /app/setup.py 15 | COPY sauna /app/sauna 16 | COPY README.rst /app/README.rst 17 | COPY docker-entrypoint.sh /app/docker-entrypoint.sh 18 | 19 | RUN set -x \ 20 | && chmod 755 /app/docker-entrypoint.sh \ 21 | && pip install /app \ 22 | && chown sauna:sauna /app 23 | 24 | USER sauna 25 | 26 | ENTRYPOINT ["/app/docker-entrypoint.sh"] 27 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | - name: Build and publish 23 | env: 24 | TWINE_USERNAME: __token__ 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py sdist bdist_wheel 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /debian/sauna.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | create_user () { 5 | 6 | adduser --system --quiet --group --no-create-home sauna || true 7 | 8 | } 9 | 10 | create_base_config_file () { 11 | 12 | if [ ! -e /etc/sauna.yml ]; then 13 | cat <> /etc/sauna.yml 14 | --- 15 | periodicity: 120 16 | 17 | consumers: 18 | Stdout: 19 | 20 | plugins: 21 | # Load average 22 | Load: 23 | checks: 24 | - type: load1 25 | warn: 2 26 | crit: 4 27 | EOF 28 | chown root:sauna /etc/sauna.yml 29 | chmod 640 /etc/sauna.yml 30 | fi 31 | 32 | } 33 | 34 | 35 | case "$1" in 36 | 37 | configure) 38 | create_user 39 | create_base_config_file ;; 40 | 41 | esac 42 | 43 | 44 | #DEBHELPER# 45 | 46 | exit 0 47 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | 7 | test: 8 | 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -e .[tests] 26 | 27 | - name: Lint 28 | run: | 29 | pycodestyle sauna tests 30 | 31 | - name: Test with pytest 32 | run: | 33 | pytest -v tests 34 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: sauna 2 | Standards-Version: 3.9.6 3 | Section: python 4 | Maintainer: Nicolas Le Manchet 5 | Build-Depends: debhelper (>= 9), dh-python, dh-systemd, 6 | python3-all, python3-setuptools 7 | Priority: extra 8 | Homepage: https://github.com/NicolasLM/sauna 9 | Vcs-Browser: https://github.com/NicolasLM/sauna 10 | Vcs-Git: https://github.com/NicolasLM/sauna.git 11 | X-Python3-Version: >= 3.2 12 | 13 | Package: sauna 14 | Pre-Depends: adduser (>= 3.40) 15 | Depends: python3 (>= 3.2), ${misc:Depends}, python3-pkg-resources, 16 | python3-docopt, python3-yaml 17 | Architecture: all 18 | Description: A simple daemon that runs and reports health checks. 19 | Sauna is a daemon written in Python that runs health checks and reports 20 | them to any monitoring server. 21 | -------------------------------------------------------------------------------- /sauna/commands/ext/list.py: -------------------------------------------------------------------------------- 1 | from sauna.commands import CommandRegister 2 | 3 | list_commands = CommandRegister() 4 | 5 | 6 | @list_commands.command(name='list-active-checks') 7 | def list_active_checks(sauna_instance, args): 8 | """Display the checks that sauna will run.""" 9 | for name in sorted(sauna_instance.get_active_checks_name()): 10 | print(name) 11 | 12 | 13 | @list_commands.command(name='list-available-checks') 14 | def list_available_checks(sauna_instance, args): 15 | """Display the available checks.""" 16 | for plugin, checks in sorted( 17 | sauna_instance.get_all_available_checks().items() 18 | ): 19 | print('{}: {}'.format(plugin, ', '.join(checks))) 20 | 21 | 22 | @list_commands.command(name='list-available-consumers') 23 | def list_available_consumers(sauna_instance, args): 24 | """Display the available consumers.""" 25 | for c in sorted(sauna_instance.get_all_available_consumers()): 26 | print(c) 27 | -------------------------------------------------------------------------------- /sauna/plugins/ext/tcp.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from sauna.plugins import (Plugin, PluginRegister) 4 | 5 | my_plugin = PluginRegister('TCP') 6 | 7 | 8 | @my_plugin.plugin() 9 | class Tcp(Plugin): 10 | 11 | @my_plugin.check() 12 | def request(self, check_config): 13 | try: 14 | with socket.create_connection((check_config['host'], 15 | check_config['port']), 16 | timeout=check_config['timeout']): 17 | pass 18 | except Exception as e: 19 | return Plugin.STATUS_CRIT, "{}".format(e) 20 | else: 21 | return Plugin.STATUS_OK, "OK" 22 | 23 | @staticmethod 24 | def config_sample(): 25 | return ''' 26 | # Tcp 27 | - type: TCP 28 | checks: 29 | - type: request 30 | host: localhost 31 | port: 11211 32 | timeout: 5 33 | ''' 34 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if test "$1" == "sample"; then 4 | sauna sample 5 | cat sauna-sample.yml 6 | exit 0 7 | elif test -f "sauna.yml"; then 8 | echo 'Using existing configuration file /app/sauna.yml' 9 | elif test ! -z "$SAUNA_CONFIG"; then 10 | echo "Using environment var SAUNA_CONFIG" 11 | echo $SAUNA_CONFIG | base64 -d > sauna.yml 12 | else 13 | echo "Cannot find configuration file sauna.yml, either:" 14 | echo " - use a volume to put configuration in /app/sauna.yml" 15 | echo " - use an environment var SAUNA_CONFIG" 16 | echo "" 17 | echo "Tip: you can get a sample of configuration with" 18 | echo "docker run nicolaslm/sauna sample" 19 | echo "" 20 | echo "Tip: you can generate the SAUNA_CONFIG data with" 21 | echo "base64 -w 0 sauna.yml" 22 | exit 1 23 | fi 24 | 25 | echo "" 26 | echo "List of active checks:" 27 | sauna list-active-checks 28 | echo "" 29 | 30 | export SAUNA_LEVEL=${SAUNA_LEVEL:=warning} 31 | 32 | exec sauna --level "$SAUNA_LEVEL" "$@" 33 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http_server/html.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | try: 4 | from html.parser.HTMLParser import escape 5 | except ImportError: 6 | from html import escape 7 | 8 | from . import HTTPServerConsumer 9 | from .dashboard_html import dashboard_html 10 | 11 | 12 | def get_check_html(): 13 | checks = HTTPServerConsumer.get_checks_as_dict() 14 | checks_html = '' 15 | for check_name, data in sorted(checks.items()): 16 | date = datetime.datetime.fromtimestamp( 17 | data['timestamp'] 18 | ).strftime('%Y-%m-%d %H:%M:%S') 19 | checks_html += "" 20 | checks_html += "{}{}" \ 21 | "{}" \ 22 | "{}".format( 23 | escape(check_name), data['code'], data['status'], 24 | escape(data['output']), date) 25 | checks_html += "" 26 | return checks_html 27 | 28 | 29 | def get_html(): 30 | template = dashboard_html 31 | checks_html = get_check_html() 32 | return template.format(checks_html).encode() 33 | -------------------------------------------------------------------------------- /sauna/plugins/ext/command.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | 4 | from sauna.plugins import Plugin, PluginRegister 5 | 6 | myplugin = PluginRegister('Command') 7 | 8 | 9 | @myplugin.plugin() 10 | class Command(Plugin): 11 | 12 | @myplugin.check() 13 | def command(self, check_config): 14 | p = subprocess.Popen( 15 | shlex.split(check_config['command']), 16 | stdout=subprocess.PIPE, 17 | stderr=subprocess.STDOUT, 18 | universal_newlines=True 19 | ) 20 | stdout, _ = p.communicate() 21 | return p.returncode, stdout 22 | 23 | @staticmethod 24 | def _return_code_to_status(cls, return_code): 25 | if return_code in (cls.STATUS_OK, cls.STATUS_WARN, cls.STATUS_CRIT): 26 | return return_code 27 | return cls.STATUS_UNKNOWN 28 | 29 | @staticmethod 30 | def config_sample(): 31 | return ''' 32 | # Execute external command 33 | # Return code is the service status 34 | - type: Command 35 | checks: 36 | - type: command 37 | name: check_website 38 | command: /opt/check_website.sh 39 | ''' 40 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. Sauna documentation master file, created by 2 | sphinx-quickstart on Fri May 6 22:17:12 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Sauna 7 | ===== 8 | 9 | Release v\ |version|. (:ref:`Installation `) 10 | 11 | Sauna is a lightweight daemon designed to run health checks and send the results to a monitoring 12 | server. 13 | 14 | Sauna comes batteries included, it is able run many system checks (load, memory, disk...) as well 15 | as monitor applications (redis, memcached, puppet...). It is easily :ref:`extensible ` to 16 | include your own checks and can even run the thousands of existing :ref:`Nagios plugins `. 17 | 18 | Painless monitoring of your servers is just a pip install away:: 19 | 20 | pip install sauna 21 | 22 | Getting started with sauna: 23 | 24 | .. toctree:: 25 | :maxdepth: 1 26 | 27 | user/install 28 | user/cli 29 | user/configuration 30 | user/service 31 | user/plugins 32 | user/consumers 33 | user/cookbook 34 | user/faq 35 | 36 | Development guide: 37 | 38 | .. toctree:: 39 | :maxdepth: 1 40 | 41 | dev/custom 42 | dev/contributing 43 | dev/internals 44 | -------------------------------------------------------------------------------- /doc/user/cli.rst: -------------------------------------------------------------------------------- 1 | .. _cli: 2 | 3 | Command Line Interface 4 | ====================== 5 | 6 | After a successful installation, the ``sauna`` command can be used to run and administer Sauna. An 7 | explanation of the available commands and flags are given with ``sauna --help``:: 8 | 9 | $ sauna --help 10 | Daemon that runs and reports health checks 11 | 12 | Usage: 13 | sauna [--level=] [--config=FILE] [ ...] 14 | sauna sample 15 | sauna (-h | --help) 16 | sauna --version 17 | 18 | Options: 19 | -h --help Show this screen. 20 | --version Show version. 21 | --level= Log level [default: warn]. 22 | --config=FILE Config file [default: sauna.yml]. 23 | 24 | Available commands: 25 | list-active-checks Display the checks that sauna will run. 26 | list-available-checks Display the available checks. 27 | list-available-consumers Display the available consumers. 28 | status Show the result of active checks. 29 | 30 | When no command is given, sauna runs in the foreground. It executes and sends the checks until 31 | interrupted. Sauna can also run as a :ref:`service ` in the background. 32 | -------------------------------------------------------------------------------- /sauna/plugins/ext/simple_domain.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from sauna.plugins import Plugin, PluginRegister 4 | 5 | my_plugin = PluginRegister('SimpleDomain') 6 | 7 | 8 | @my_plugin.plugin() 9 | class SimpleDomain(Plugin): 10 | 11 | @my_plugin.check() 12 | def request(self, check_config): 13 | domain = check_config.get('domain') 14 | if check_config.get('ip_version') == 6: 15 | af = socket.AF_INET6 16 | elif check_config.get('ip_version') == 4: 17 | af = socket.AF_INET 18 | else: 19 | af = 0 20 | 21 | try: 22 | result = socket.getaddrinfo(domain, 0, af) 23 | except Exception as e: 24 | return Plugin.STATUS_CRIT, '{}'.format(e) 25 | 26 | ips = [ip[4][0] for ip in result] 27 | return ( 28 | Plugin.STATUS_OK, 29 | 'Domain was resolved with {}'.format(', '.join(ips)) 30 | ) 31 | 32 | @staticmethod 33 | def config_sample(): 34 | return ''' 35 | # Make a domain request, 36 | # crit if domain can't be resolved 37 | - type: SimpleDomain 38 | checks: 39 | - type: request 40 | domain: www.website.tld 41 | ip_version: 4 # 4 or 6; default is both 42 | 43 | ''' 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Nicolas Le Manchet 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http_server/dashboard_html.py: -------------------------------------------------------------------------------- 1 | dashboard_html = """ 2 | 3 | 4 | 5 | Sauna status 6 | 53 | 54 | 55 |
56 | 57 | 58 | {} 59 |
Sauna status
60 |
61 | 62 | """ 63 | -------------------------------------------------------------------------------- /sauna/plugins/ext/load.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sauna.plugins import Plugin 4 | from sauna.plugins import PluginRegister 5 | 6 | my_plugin = PluginRegister('Load') 7 | 8 | 9 | @my_plugin.plugin() 10 | class Load(Plugin): 11 | 12 | def __init__(self, config): 13 | super().__init__(config) 14 | self._load = None 15 | 16 | @my_plugin.check() 17 | def load1(self, check_config): 18 | return (self._value_to_status_less(self.load[0], check_config), 19 | 'Load 1: {}'.format(self.load[0])) 20 | 21 | @my_plugin.check() 22 | def load5(self, check_config): 23 | return (self._value_to_status_less(self.load[1], check_config), 24 | 'Load 5: {}'.format(self.load[1])) 25 | 26 | @my_plugin.check() 27 | def load15(self, check_config): 28 | return (self._value_to_status_less(self.load[2], check_config), 29 | 'Load 15: {}'.format(self.load[2])) 30 | 31 | @property 32 | def load(self): 33 | if not self._load: 34 | self._load = os.getloadavg() 35 | return self._load 36 | 37 | @staticmethod 38 | def config_sample(): 39 | return ''' 40 | # Load average 41 | - type: Load 42 | checks: 43 | - type: load1 44 | warn: 2 45 | crit: 4 46 | - type: load5 47 | warn: 2 48 | crit: 4 49 | - type: load15 50 | warn: 2 51 | crit: 4 52 | ''' 53 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http.py: -------------------------------------------------------------------------------- 1 | from sauna.consumers.base import QueuedConsumer 2 | from sauna.consumers import ConsumerRegister 3 | 4 | my_consumer = ConsumerRegister('HTTP') 5 | 6 | 7 | @my_consumer.consumer() 8 | class HTTPConsumer(QueuedConsumer): 9 | 10 | def __init__(self, config): 11 | super().__init__(config) 12 | try: 13 | import requests 14 | self.requests = requests 15 | except ImportError: 16 | from ... import DependencyError 17 | raise DependencyError(self.__class__.__name__, 'requests', 18 | 'requests', 'python3-requests') 19 | self.config = { 20 | 'url': config.get('url', 'http://localhost'), 21 | 'timeout': config.get('timeout', 60), 22 | 'headers': config.get('headers', None) 23 | } 24 | 25 | def _send(self, service_check): 26 | data = { 27 | 'timestamp': service_check.timestamp, 28 | 'hostname': service_check.hostname, 29 | 'service': service_check.name, 30 | 'status': service_check.status, 31 | 'output': service_check.output 32 | } 33 | response = self.requests.post( 34 | self.config['url'], timeout=self.config['timeout'], 35 | headers=self.config['headers'], json=data 36 | ) 37 | response.raise_for_status() 38 | 39 | @staticmethod 40 | def config_sample(): 41 | return ''' 42 | # Posts a service check trough HTTP 43 | # Payload is serialized in JSON 44 | - type: HTTP 45 | url: http://server.tld/services 46 | timeout: 60 47 | headers: 48 | X-Auth-Token: XaiZevii0thaemaezaeJ 49 | ''' 50 | -------------------------------------------------------------------------------- /sauna/plugins/ext/mdstat.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, PluginRegister 2 | 3 | my_plugin = PluginRegister('MDStat') 4 | 5 | 6 | @my_plugin.plugin() 7 | class MDStat(Plugin): 8 | 9 | def __init__(self, config): 10 | super().__init__(config) 11 | try: 12 | import pymdstat 13 | self.pymdstat = pymdstat 14 | except ImportError: 15 | from ... import DependencyError 16 | raise DependencyError(self.__class__.__name__, 'pymdstat', 17 | 'pymdstat') 18 | self._md_stats = None 19 | 20 | @property 21 | def md_stats(self): 22 | if not self._md_stats: 23 | self._md_stats = self.pymdstat.MdStat().get_stats() 24 | return self._md_stats 25 | 26 | @my_plugin.check() 27 | def status(self, check_config): 28 | if not self.md_stats['arrays']: 29 | return self.STATUS_UNKNOWN, 'No RAID array detected' 30 | 31 | for array_name, array_infos in self.md_stats['arrays'].items(): 32 | if array_infos['status'] != 'active': 33 | return self.STATUS_CRIT, '{} is in status {}'.format( 34 | array_name, array_infos['status'] 35 | ) 36 | if array_infos['used'] != array_infos['available']: 37 | return self.STATUS_CRIT, '{} uses {}/{} devices'.format( 38 | array_name, array_infos['used'], array_infos['available'] 39 | ) 40 | 41 | return self.STATUS_OK, 'All arrays are healthy' 42 | 43 | @staticmethod 44 | def config_sample(): 45 | return ''' 46 | # Linux MD RAID arrays 47 | - type: MDStat 48 | checks: 49 | - type: status 50 | ''' 51 | -------------------------------------------------------------------------------- /sauna/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins.base import Plugin 2 | 3 | 4 | def bytes_to_human(n): 5 | symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') 6 | prefix = {} 7 | for i, s in enumerate(symbols): 8 | prefix[s] = 1 << (i + 1) * 10 9 | for s in reversed(symbols): 10 | if n >= prefix[s]: 11 | value = float(n) / prefix[s] 12 | return '%.1f%s' % (value, s) 13 | return "%sB" % n 14 | 15 | 16 | def human_to_bytes(size): 17 | symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') 18 | size = size.upper() 19 | for i, s in enumerate(symbols): 20 | if s in size: 21 | base = int(size.split(s)[0]) 22 | return base * (1024 ** (i+1)) 23 | return int(size) 24 | 25 | 26 | class PluginRegister: 27 | all_plugins = {} 28 | 29 | def __init__(self, name): 30 | self.name = name 31 | self.plugin_class = None 32 | self.checks = {} 33 | 34 | def check(self, **options): 35 | def decorator(func): 36 | check_name = options.pop("name", func.__name__) 37 | self.checks[check_name] = func.__name__ 38 | return func 39 | return decorator 40 | 41 | def plugin(self): 42 | def decorator(plugin_cls): 43 | self.plugin_class = plugin_cls 44 | # At this point all data should be set 45 | self.all_plugins[self.name] = { 46 | 'plugin_cls': self.plugin_class, 47 | 'checks': self.checks 48 | } 49 | return plugin_cls 50 | return decorator 51 | 52 | @classmethod 53 | def get_plugin(cls, name): 54 | try: 55 | return cls.all_plugins[name] 56 | except KeyError: 57 | return None 58 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http_icinga.py: -------------------------------------------------------------------------------- 1 | # forked from http.py to match icinga Rest API 2 | from sauna.consumers.base import QueuedConsumer 3 | from sauna.consumers import ConsumerRegister 4 | 5 | my_consumer = ConsumerRegister('HTTP-icinga') 6 | 7 | 8 | @my_consumer.consumer() 9 | class HTTPIcingaConsumer(QueuedConsumer): 10 | 11 | def __init__(self, config): 12 | super().__init__(config) 13 | try: 14 | import requests 15 | self.requests = requests 16 | except ImportError: 17 | from ... import DependencyError 18 | raise DependencyError(self.__class__.__name__, 'requests', 19 | 'requests', 'python3-requests') 20 | self.config = { 21 | 'url': config.get('url', 'http://localhost'), 22 | 'timeout': config.get('timeout', 60), 23 | 'headers': config.get('headers', None) 24 | } 25 | 26 | def _send(self, service_check): 27 | data = { 28 | "filter": ( 29 | "host.name==\"" + service_check.hostname + 30 | "\" && service.name==\"" + service_check.name + "\"" 31 | ), 32 | "exit_status": service_check.status, 33 | "plugin_output": service_check.output, 34 | "type": "Service" 35 | } 36 | response = self.requests.post( 37 | self.config['url'], timeout=self.config['timeout'], 38 | headers=self.config['headers'], json=data 39 | ) 40 | response.raise_for_status() 41 | 42 | @staticmethod 43 | def config_sample(): 44 | return ''' 45 | # Posts a service check trough HTTP to Icinga 46 | # Payload is serialized in JSON 47 | - type: HTTP-icinga 48 | url: http://icinga.host:5665/v1/actions/process-check-result 49 | timeout: 60 50 | headers: 51 | accept: application/json 52 | authorization: ICINGA_BASIC 53 | ''' 54 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Sauna 2 | ===== 3 | 4 | Sauna is a lightweight daemon designed to run health checks and send the results to a monitoring 5 | server. 6 | 7 | Sauna comes batteries included, it is able run many system checks (load, memory, disk...) as well 8 | as monitor applications (redis, memcached, puppet...). It is easily extensible to include your own 9 | checks and can even run the thousands of existing Nagios plugins. 10 | 11 | Installation 12 | ------------ 13 | 14 | You can install it with pip:: 15 | 16 | pip install sauna 17 | 18 | See the `documentation `_ for other 19 | installation methods. 20 | 21 | Documentation 22 | ------------- 23 | 24 | Documentation for sauna is available at `sauna.readthedocs.io 25 | `_. 26 | 27 | Plugins 28 | ~~~~~~~ 29 | 30 | Plugins are optional modules that provide a set of checks. You only opt-in for the plugins that 31 | make sense for your setup. Available plugins are: 32 | 33 | * Load average 34 | * Memory and swap usage 35 | * Disk partition usage 36 | * Processes and file descriptors 37 | * Redis 38 | * External command 39 | * Puppet agent 40 | * Postfix 41 | * Memcached 42 | * HTTP servers 43 | 44 | Consumers 45 | ~~~~~~~~~ 46 | 47 | Consumers on the other hand provide a way for checks to be processed by a monitoring server. 48 | Available consumers are: 49 | 50 | * NSCA 51 | * HTTP 52 | * TCP server 53 | * Stdout 54 | 55 | Contributing 56 | ------------ 57 | 58 | Sauna is written in Python 3. Adding a check plugin or a consumer should be straightforward. Clone 59 | the repository and install it in development mode in a virtualenv:: 60 | 61 | pip install -e . 62 | 63 | The code base follows pep8, test the code for compliance with:: 64 | 65 | pep8 sauna tests 66 | 67 | Run the test suite:: 68 | 69 | nosetests 70 | 71 | More information about how to contribute are available on the `development guide 72 | `_. 73 | 74 | License 75 | ------- 76 | 77 | BSD 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | from sauna import __version__ as version 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | 9 | # Get the long description from the README file 10 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | name='sauna', 15 | version=version, 16 | description='Daemon that runs and reports health checks', 17 | long_description=long_description, 18 | url='https://github.com/NicolasLM/sauna', 19 | author='Nicolas Le Manchet', 20 | author_email='nicolas@lemanchet.fr', 21 | license='BSD', 22 | 23 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 24 | classifiers=[ 25 | 'Development Status :: 3 - Alpha', 26 | 'Environment :: Console', 27 | 'Intended Audience :: System Administrators', 28 | 'Topic :: System :: Monitoring', 29 | 'Topic :: System :: Systems Administration', 30 | 'License :: OSI Approved :: BSD License', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3 :: Only', 33 | 'Programming Language :: Python :: 3.6', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | ], 39 | 40 | keywords='monitoring health checks nagios shinken', 41 | 42 | packages=find_packages(exclude=['tests']), 43 | 44 | install_requires=[ 45 | 'docopt', 46 | 'PyYAML' 47 | ], 48 | 49 | extras_require={ 50 | 'tests': [ 51 | 'pytest', 52 | 'pycodestyle', 53 | 'requests-mock', 54 | 'pymdstat', 55 | 'jsonpath_rw', 56 | 'psutil' 57 | ], 58 | }, 59 | 60 | entry_points={ 61 | 'console_scripts': [ 62 | 'sauna = sauna.main:main', 63 | ], 64 | }, 65 | 66 | ) 67 | -------------------------------------------------------------------------------- /sauna/plugins/ext/puppet_agent.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import timedelta 3 | 4 | from sauna.plugins import Plugin, PluginRegister 5 | 6 | my_plugin = PluginRegister('PuppetAgent') 7 | 8 | 9 | @my_plugin.plugin() 10 | class PuppetAgent(Plugin): 11 | 12 | def __init__(self, config): 13 | super().__init__(config) 14 | self.config = { 15 | 'summary_path': config.get( 16 | 'summary_path', 17 | '/var/lib/puppet/state/last_run_summary.yaml' 18 | ) 19 | } 20 | self._last_run_summary = None 21 | 22 | @property 23 | def last_run_summary(self): 24 | import yaml 25 | if not self._last_run_summary: 26 | with open(self.config['summary_path']) as f: 27 | self._last_run_summary = yaml.safe_load(f) 28 | return self._last_run_summary 29 | 30 | @my_plugin.check() 31 | def last_run_delta(self, check_config): 32 | current_time = int(time.time()) 33 | last_run_time = self.last_run_summary['time']['last_run'] 34 | delta = current_time - last_run_time 35 | status = self._value_to_status_less(delta, check_config) 36 | output = 'Puppet last ran {} ago'.format(timedelta(seconds=delta)) 37 | return status, output 38 | 39 | @my_plugin.check() 40 | def failures(self, check_config): 41 | failures = self.last_run_summary['events']['failure'] 42 | status = self._value_to_status_less(failures, check_config) 43 | if failures: 44 | output = 'Puppet last run had {} failure(s)'.format(failures) 45 | else: 46 | output = 'Puppet ran without trouble' 47 | return status, output 48 | 49 | @staticmethod 50 | def config_sample(): 51 | return ''' 52 | # Puppet agent 53 | # sauna user must be able to read: 54 | # /var/lib/puppet/state/last_run_summary.yaml 55 | - type: PuppetAgent 56 | checks: 57 | - type: last_run_delta 58 | warn: 3600 59 | crit: 14400 60 | - type: failures 61 | warn: 1 62 | crit: 1 63 | ''' 64 | -------------------------------------------------------------------------------- /sauna/plugins/ext/ntpd.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import timedelta 3 | import os 4 | 5 | from sauna.plugins import Plugin, PluginRegister 6 | 7 | my_plugin = PluginRegister('Ntpd') 8 | 9 | 10 | @my_plugin.plugin() 11 | class Ntpd(Plugin): 12 | 13 | def __init__(self, config): 14 | super().__init__(config) 15 | self.config = { 16 | 'stats_dir': config.get('stats_dir', '/var/log/ntpstats') 17 | } 18 | self._last_loop_stats = None 19 | 20 | @property 21 | def last_loop_stats(self): 22 | loopstats_file = os.path.join(self.config['stats_dir'], 'loopstats') 23 | if not self._last_loop_stats: 24 | with open(loopstats_file) as f: 25 | last_line_items = f.readlines()[-1].split() 26 | self._last_loop_stats = { 27 | 'timestamp': int(os.stat(loopstats_file).st_mtime), 28 | 'offset': float(last_line_items[2]) 29 | } 30 | return self._last_loop_stats 31 | 32 | @my_plugin.check() 33 | def last_sync_delta(self, check_config): 34 | current_time = int(time.time()) 35 | delta = current_time - self.last_loop_stats['timestamp'] 36 | status = self._value_to_status_less(delta, check_config) 37 | output = 'Ntp sync {} ago'.format(timedelta(seconds=delta)) 38 | return status, output 39 | 40 | @my_plugin.check() 41 | def offset(self, check_config): 42 | status = self._value_to_status_less( 43 | abs(self.last_loop_stats['offset']), check_config 44 | ) 45 | output = 'Last time offset: {0:.3f}s'.format( 46 | self.last_loop_stats['offset'] 47 | ) 48 | return status, output 49 | 50 | @staticmethod 51 | def config_sample(): 52 | return ''' 53 | # ntpd 54 | # Enable statistics in /etc/ntp.conf: 55 | # statsdir /var/log/ntpstats/ 56 | - type: Ntpd 57 | checks: 58 | - type: offset 59 | warn: 0.500 60 | crit: 2.0 61 | # Last synchronization data available 62 | - type: last_sync_delta 63 | warn: 2800 64 | crit: 3600 65 | ''' 66 | -------------------------------------------------------------------------------- /sauna/plugins/ext/disk.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sauna.plugins import PluginRegister 4 | from sauna.plugins.base import PsutilPlugin 5 | 6 | my_plugin = PluginRegister('Disk') 7 | 8 | 9 | @my_plugin.plugin() 10 | class Disk(PsutilPlugin): 11 | 12 | @my_plugin.check() 13 | def used_percent(self, check_config): 14 | check_config = self._strip_percent_sign_from_check_config(check_config) 15 | for part in self.psutil.disk_partitions(all=False): 16 | part_usage = self.psutil.disk_usage(part.mountpoint).percent 17 | status = self._value_to_status_less(part_usage, check_config) 18 | if status > 0: 19 | return ( 20 | status, 21 | 'Partition {} is full at {}%'.format(part.mountpoint, 22 | part_usage) 23 | ) 24 | return 0, 'Disk usage correct' 25 | 26 | @my_plugin.check() 27 | def used_inodes_percent(self, check_config): 28 | check_config = self._strip_percent_sign_from_check_config(check_config) 29 | for part in self.psutil.disk_partitions(all=False): 30 | s = os.statvfs(part.mountpoint) 31 | try: 32 | inodes_usage = int((s.f_files - s.f_favail) * 100 / s.f_files) 33 | except ZeroDivisionError: 34 | continue 35 | status = self._value_to_status_less( 36 | inodes_usage, check_config, self._strip_percent_sign 37 | ) 38 | if status != self.STATUS_OK: 39 | return ( 40 | status, 41 | 'Partition {} uses {}% of inodes'.format(part.mountpoint, 42 | inodes_usage) 43 | ) 44 | return self.STATUS_OK, 'Inodes usage correct' 45 | 46 | @staticmethod 47 | def config_sample(): 48 | return ''' 49 | # Usage of disks 50 | - type: Disk 51 | checks: 52 | - type: used_percent 53 | warn: 80% 54 | crit: 90% 55 | - type: used_inodes_percent 56 | warn: 80% 57 | crit: 90% 58 | ''' 59 | -------------------------------------------------------------------------------- /doc/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | Prerequisites 7 | ------------- 8 | 9 | Sauna is written in Python 3, prior to use it you must make sure you have a Python 3 interpreter on 10 | your system. 11 | 12 | Pip 13 | --- 14 | 15 | If you are familiar with the Python ecosystem, you won't be surprised that sauna can be installed 16 | with:: 17 | 18 | $ pip install sauna 19 | 20 | That's it, you can call it a day! 21 | 22 | Debian package 23 | -------------- 24 | 25 | A Debian package for Jessie is built with each release, you can find them on `GitHub 26 | `_. 27 | 28 | Download and install the deb package:: 29 | 30 | $ wget https://github.com/NicolasLM/sauna/releases/download//sauna_-1_all.deb 31 | $ dkpg -i sauna_-1_all.deb || apt-get install -f 32 | 33 | The configuration file will be located at ``/etc/sauna.yml`` and sauna will be launched 34 | automatically on boot. 35 | 36 | Source Code 37 | ----------- 38 | 39 | Sauna is developed on GitHub, you can find the code at `NicolasLM/sauna 40 | `_. 41 | 42 | You can clone the public repository:: 43 | 44 | $ git clone https://github.com/NicolasLM/sauna.git 45 | 46 | Once you have the sources, simply install it with:: 47 | 48 | $ python setup.py install 49 | 50 | If you are interested in writing your own checks, head up to the :ref:`development `. 51 | 52 | Docker image 53 | ------------ 54 | 55 | A Docker image is available on the `Docker Hub `_. It 56 | allows to run sauna in a container:: 57 | 58 | $ docker pull nicolaslm/sauna 59 | 60 | If you want to share the configuration between the host and the container using a volume, be 61 | carreful about file permissions. 62 | 63 | The configuration file might contains some sensible data like password and should not be readable 64 | for everyone. But inside the container sauna runs as user *sauna* (uid 4343) and need to read the 65 | configuration file. To do so, the easiest way is to create a user on the host with the same uid 66 | (4343) and chown the configuration file with this user. Then you can mount the configuration file 67 | inside the container and run sauna with :: 68 | 69 | $ docker run -v /etc/sauna.yml:/app/sauna.yml:ro nicolaslm/sauna 70 | -------------------------------------------------------------------------------- /sauna/plugins/ext/memory.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins.base import PsutilPlugin 2 | from sauna.plugins import human_to_bytes, bytes_to_human, PluginRegister 3 | 4 | my_plugin = PluginRegister('Memory') 5 | 6 | 7 | @my_plugin.plugin() 8 | class Memory(PsutilPlugin): 9 | 10 | def __init__(self, config): 11 | super().__init__(config) 12 | self._virtual_memory = None 13 | self._swap_memory = None 14 | 15 | @my_plugin.check() 16 | def available(self, check_config): 17 | available = self.virtual_memory.available 18 | return ( 19 | self._value_to_status_more(available, check_config, 20 | human_to_bytes), 21 | 'Memory available: {}'.format(bytes_to_human(available)) 22 | ) 23 | 24 | @my_plugin.check() 25 | def used_percent(self, check_config): 26 | used_percent = self.virtual_memory.percent 27 | check_config = self._strip_percent_sign_from_check_config(check_config) 28 | return ( 29 | self._value_to_status_less(used_percent, check_config), 30 | 'Memory used: {}%'.format(used_percent) 31 | ) 32 | 33 | @my_plugin.check() 34 | def swap_used_percent(self, check_config): 35 | swap_used_percent = self.swap_memory.percent 36 | check_config = self._strip_percent_sign_from_check_config(check_config) 37 | return ( 38 | self._value_to_status_less(swap_used_percent, check_config), 39 | 'Swap used: {}%'.format(swap_used_percent) 40 | ) 41 | 42 | @property 43 | def virtual_memory(self): 44 | if not self._virtual_memory: 45 | self._virtual_memory = self.psutil.virtual_memory() 46 | return self._virtual_memory 47 | 48 | @property 49 | def swap_memory(self): 50 | if not self._swap_memory: 51 | self._swap_memory = self.psutil.swap_memory() 52 | return self._swap_memory 53 | 54 | @staticmethod 55 | def config_sample(): 56 | return ''' 57 | # System memory 58 | - type: Memory 59 | checks: 60 | - type: available 61 | warn: 6G 62 | crit: 2G 63 | - type: used_percent 64 | warn: 80% 65 | crit: 90% 66 | - type: swap_used_percent 67 | warn: 50% 68 | crit: 70% 69 | ''' 70 | -------------------------------------------------------------------------------- /doc/user/service.rst: -------------------------------------------------------------------------------- 1 | .. _service: 2 | 3 | Launching on boot 4 | ================= 5 | 6 | You will probably want to launch sauna as a service as opposed as attached to a shell. This page 7 | presents a few possibilities for doing that. 8 | 9 | .. note:: If you installed sauna via the Debian package, everything is already taken care of and 10 | you can skip this part. 11 | 12 | Creating a user 13 | --------------- 14 | 15 | Sauna does not need to run as root, following the principle of least privilege, you should create a 16 | user dedicated to sauna:: 17 | 18 | adduser --system --quiet --group --no-create-home sauna 19 | 20 | Systemd 21 | ------- 22 | 23 | If your distribution comes with the systemd init system, launching sauna on boot is simple. 24 | Create a :download:`systemd unit file <../../debian/sauna.service>` at 25 | ``/etc/systemd/system/sauna.service``:: 26 | 27 | [Unit] 28 | Description=Sauna health check daemon 29 | Wants=network-online.target 30 | After=network-online.target 31 | 32 | [Service] 33 | Type=simple 34 | ExecStart=/opt/sauna/bin/sauna --config /etc/sauna.yml 35 | User=sauna 36 | 37 | [Install] 38 | WantedBy=multi-user.target 39 | 40 | Indicate in ``ExecStart`` the location where you installed sauna and in ``User`` which user will 41 | run sauna. 42 | 43 | Enable the unit, as root:: 44 | 45 | # systemctl daemon-reload 46 | # systemctl enable sauna.service 47 | Created symlink to /etc/systemd/system/sauna.service. 48 | # systemctl start sauna.service 49 | # systemctl status sauna.service 50 | ● sauna.service - Sauna health check daemon 51 | Loaded: loaded (/etc/systemd/system/sauna.service; enabled) 52 | Active: active (running) since Sat 2016-05-14 13:13:16 CEST; 1min 17s ago 53 | Main PID: 30613 (sauna) 54 | CGroup: /system.slice/sauna.service 55 | └─30613 /opt/sauna/bin/python3.4 /opt/sauna/bin/sauna --config /etc/sauna.yml 56 | 57 | Supervisor 58 | ---------- 59 | 60 | `Supervisor `_ is a lightweight process control system used in 61 | addition of init systems like systemd or SysVinit. 62 | 63 | Create a supervisor definition for sauna:: 64 | 65 | [program:sauna] 66 | command=/opt/sauna/bin/sauna --config /etc/sauna.yml 67 | user=sauna 68 | 69 | Load the new configuration:: 70 | 71 | $ supervisorctl reread 72 | $ supervisorctl update 73 | -------------------------------------------------------------------------------- /sauna/plugins/ext/apt.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, PluginRegister 2 | 3 | my_plugin = PluginRegister('Apt') 4 | 5 | 6 | @my_plugin.plugin() 7 | class AptPlugin(Plugin): 8 | 9 | def __init__(self, config): 10 | super().__init__(config) 11 | try: 12 | import apt 13 | self._apt = apt 14 | except ImportError: 15 | from ... import DependencyError 16 | raise DependencyError( 17 | self.__class__.__name__, 18 | 'apt', 19 | deb='python3-apt' 20 | ) 21 | self._packages = None 22 | 23 | @property 24 | def packages(self) -> list: 25 | if self._packages is None: 26 | with self._apt.Cache() as cache: 27 | cache.upgrade() # Only reads the packages to upgrade 28 | self._packages = cache.get_changes() 29 | return self._packages 30 | 31 | @my_plugin.check() 32 | def security_updates(self, check_config): 33 | num_security_packages = 0 34 | for p in self.packages: 35 | for o in p.candidate.origins: 36 | if 'security' in o.codename.lower(): 37 | num_security_packages += 1 38 | break 39 | 40 | if 'security' in o.label.lower(): 41 | num_security_packages += 1 42 | break 43 | 44 | if 'security' in o.site.lower(): 45 | num_security_packages += 1 46 | break 47 | 48 | if num_security_packages == 0: 49 | return self.STATUS_OK, 'No security updates' 50 | 51 | return ( 52 | self.STATUS_WARN, 53 | f'{num_security_packages} packages with security updates' 54 | ) 55 | 56 | @my_plugin.check() 57 | def package_updates(self, check_config): 58 | if not self.packages: 59 | return self.STATUS_OK, 'No package updates' 60 | 61 | return ( 62 | self.STATUS_WARN, 63 | f'{len(self.packages)} packages updates' 64 | ) 65 | 66 | @staticmethod 67 | def config_sample(): 68 | return ''' 69 | # Debian APT 70 | # This only consults the local APT cache, it does not 71 | # run an 'apt update'. Use 'unattended-upgrades' for that. 72 | - type: Apt 73 | checks: 74 | - type: security_updates 75 | - type: package_updates 76 | ''' 77 | -------------------------------------------------------------------------------- /doc/dev/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | Contributing 4 | ============ 5 | 6 | This page contains the few guidelines and conventions used in the code base. 7 | 8 | Pull requests 9 | ------------- 10 | 11 | The development of sauna happens on GitHub, the main repository is 12 | `https://github.com/NicolasLM/sauna `_. To contribute to sauna: 13 | 14 | * Fork ``NicolasLM/sauna`` 15 | * Clone your fork 16 | * Create a feature branch ``git checkout -b my_feature`` 17 | * Commit your changes 18 | * Push your changes to your fork ``git push origin my_feature`` 19 | * Create a GitHub pull request against ``NicolasLM/sauna``'s master branch 20 | 21 | .. note:: Avoid including multiple commits in your pull request, unless it adds value to a future 22 | reader. If you need to modify a commit, ``git commit --amend`` is your friend. Write a 23 | meaningful commit message, see `How to write a commit message 24 | `_. 25 | 26 | Python sources 27 | -------------- 28 | 29 | The code base follows `pep8 `_ guidelines with lines 30 | wrapping at the 79th character. You can verify that the code follows the conventions with:: 31 | 32 | $ pep8 sauna tests 33 | 34 | Running tests is an invaluable help when adding a new feature or when refactoring. Try to add the 35 | proper test cases in ``tests/`` together with your patch. The test suite can be run with nose:: 36 | 37 | $ nosetests 38 | .................................... 39 | ---------------------------------------------------- 40 | Ran 36 tests in 0.050s 41 | 42 | OK 43 | 44 | Compatibility 45 | ------------- 46 | 47 | Sauna runs on all versions of Python 3 starting from 3.2. Tests are run on Travis to ensure that. 48 | Except from a few import statements, this is usually not an issue. 49 | 50 | Documentation sources 51 | --------------------- 52 | 53 | Documentation is located in the ``doc`` directory of the repository. It is written in 54 | `reStructuredText `_ and built 55 | with `Sphinx `_. 56 | 57 | For ``.rst`` files, the line length is 99 chars as opposed of the 79 chars of python sources. 58 | 59 | If you modify the docs, make sure it builds without errors:: 60 | 61 | $ cd doc/ 62 | $ make html 63 | 64 | The generated HTML pages should land in ``doc/_build/html``. 65 | -------------------------------------------------------------------------------- /sauna/plugins/ext/redis.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, human_to_bytes, bytes_to_human,\ 2 | PluginRegister 3 | 4 | my_plugin = PluginRegister('Redis') 5 | 6 | 7 | @my_plugin.plugin() 8 | class Redis(Plugin): 9 | 10 | def __init__(self, config): 11 | super().__init__(config) 12 | try: 13 | import redis 14 | self.redis = redis 15 | except ImportError: 16 | from ... import DependencyError 17 | raise DependencyError(self.__class__.__name__, 'redis-py', 18 | 'redis', 'python3-redis') 19 | self._redis_info = None 20 | 21 | @my_plugin.check() 22 | def used_memory(self, check_config): 23 | status = self._value_to_status_less( 24 | self.redis_info['used_memory'], check_config, human_to_bytes 25 | ) 26 | output = 'Used memory: {}'.format(self.redis_info['used_memory_human']) 27 | return status, output 28 | 29 | @my_plugin.check() 30 | def used_memory_rss(self, check_config): 31 | status = self._value_to_status_less( 32 | self.redis_info['used_memory_rss'], check_config, human_to_bytes 33 | ) 34 | output = 'Used memory RSS: {}'.format( 35 | bytes_to_human(self.redis_info['used_memory_rss']) 36 | ) 37 | return status, output 38 | 39 | @property 40 | def redis_info(self): 41 | if not self._redis_info: 42 | r = self.redis.StrictRedis(**self.config) 43 | self._redis_info = r.info() 44 | return self._redis_info 45 | 46 | @my_plugin.check() 47 | def llen(self, check_config): 48 | r = self.redis.StrictRedis(**self.config) 49 | num_items = r.llen(check_config['key']) 50 | status = self._value_to_status_less(num_items, check_config) 51 | output = '{} items in key {}'.format(num_items, check_config['key']) 52 | return status, output 53 | 54 | @staticmethod 55 | def config_sample(): 56 | return ''' 57 | # Redis 58 | - type: Redis 59 | checks: 60 | - type: used_memory 61 | warn: 128M 62 | crit: 1024M 63 | - type: used_memory_rss 64 | warn: 128M 65 | crit: 1024M 66 | # Check the size of a list 67 | - type: llen 68 | key: celery 69 | warn: 10 70 | crit: 20 71 | config: 72 | host: localhost 73 | port: 6379 74 | ''' 75 | -------------------------------------------------------------------------------- /tests/test_scheduler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | try: 3 | from unittest import mock 4 | except ImportError: 5 | # Python 3.2 does not have mock in the standard library 6 | import mock 7 | 8 | from sauna.scheduler import Scheduler, Job 9 | 10 | 11 | class SchedulerTest(unittest.TestCase): 12 | 13 | def test_scheduler_3_jobs(self): 14 | """ 15 | Test scheduler with 3 jobs, one every 1, 2 and 3 seconds. 16 | 17 | 0 1 2 3 4 5 18 | --------------------- 19 | | | | | | | 20 | A A A A A A 21 | B B B 22 | C C 23 | """ 24 | mock1, mock2, mock3 = mock.Mock(), mock.Mock(), mock.Mock() 25 | ja, jb, jc = Job(1, mock1), Job(2, mock2), Job(3, mock3) 26 | s = Scheduler([ja, jb, jc]) 27 | 28 | self.assertEqual(s.tick_duration, 1) 29 | self.assertEqual(s._ticks, 6) 30 | 31 | self.assertListEqual(next(s), [ja, jb, jc]) 32 | self.assertListEqual(next(s), [ja]) 33 | self.assertListEqual(next(s), [ja, jb]) 34 | self.assertListEqual(next(s), [ja, jc]) 35 | self.assertListEqual(next(s), [ja, jb]) 36 | self.assertListEqual(next(s), [ja]) 37 | 38 | self.assertEqual(mock1.call_count, 6) 39 | self.assertEqual(mock2.call_count, 3) 40 | self.assertEqual(mock3.call_count, 2) 41 | 42 | def test_scheduler_2_jobs(self): 43 | """Jobs every 1 and 5 min.""" 44 | ja, jb = Job(60, lambda: None), Job(300, lambda: None) 45 | s = Scheduler([ja, jb]) 46 | 47 | self.assertEqual(s.tick_duration, 60) 48 | self.assertEqual(s._ticks, 5) 49 | 50 | self.assertListEqual(next(s), [ja, jb]) 51 | for _ in range(4): 52 | self.assertListEqual(next(s), [ja]) 53 | self.assertListEqual(next(s), [ja, jb]) 54 | 55 | def test_number_of_ticks(self): 56 | self.assertEqual( 57 | Scheduler.find_minimum_ticks_required(1, {13, 15}), 58 | 195 59 | ) 60 | self.assertEqual( 61 | Scheduler.find_minimum_ticks_required(1, {5, 13, 1, 15}), 62 | 195 63 | ) 64 | 65 | 66 | class JobTest(unittest.TestCase): 67 | 68 | def test_execute_job(self): 69 | mock1 = mock.Mock() 70 | job = Job(10, mock1, 'foo', 1, bar='baz') 71 | job() 72 | self.assertTrue(mock1.called) 73 | self.assertEqual(str(mock1.call_args), "call('foo', 1, bar='baz')") 74 | 75 | def test_non_callable_job(self): 76 | with self.assertRaises(ValueError): 77 | Job(10, 'foo') 78 | -------------------------------------------------------------------------------- /sauna/plugins/ext/disque.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, human_to_bytes, bytes_to_human,\ 2 | PluginRegister 3 | 4 | my_plugin = PluginRegister('Disque') 5 | 6 | 7 | @my_plugin.plugin() 8 | class Disque(Plugin): 9 | 10 | def __init__(self, config): 11 | super().__init__(config) 12 | try: 13 | import redis 14 | self.redis = redis 15 | except ImportError: 16 | from ... import DependencyError 17 | raise DependencyError(self.__class__.__name__, 'redis-py', 18 | 'redis', 'python3-redis') 19 | self._disque_info = None 20 | 21 | @my_plugin.check() 22 | def used_memory(self, check_config): 23 | status = self._value_to_status_less( 24 | self.disque_info['used_memory'], check_config, human_to_bytes 25 | ) 26 | output = 'Used memory: {}'.format( 27 | self.disque_info['used_memory_human']) 28 | return status, output 29 | 30 | @my_plugin.check() 31 | def used_memory_rss(self, check_config): 32 | status = self._value_to_status_less( 33 | self.disque_info['used_memory_rss'], check_config, human_to_bytes 34 | ) 35 | output = 'Used memory RSS: {}'.format( 36 | bytes_to_human(self.disque_info['used_memory_rss']) 37 | ) 38 | return status, output 39 | 40 | @property 41 | def disque_info(self): 42 | if not self._disque_info: 43 | r = self.redis.StrictRedis(**self.config) 44 | self._disque_info = r.info() 45 | return self._disque_info 46 | 47 | @my_plugin.check() 48 | def qlen(self, check_config): 49 | r = self.redis.StrictRedis(**self.config) 50 | num_items = r.execute_command('QLEN', check_config['key']) 51 | status = self._value_to_status_less(num_items, check_config) 52 | output = '{} items in key {}'.format(num_items, check_config['key']) 53 | return status, output 54 | 55 | @staticmethod 56 | def config_sample(): 57 | return ''' 58 | # Disque, an in-memory, distributed job queue 59 | # This is a Redis fork, https://github.com/antirez/disque 60 | - type: Disque 61 | checks: 62 | - type: used_memory 63 | warn: 128M 64 | crit: 1024M 65 | - type: used_memory_rss 66 | warn: 128M 67 | crit: 1024M 68 | # Check the size of a queue 69 | - type: qlen 70 | key: my-queue 71 | warn: 10 72 | crit: 20 73 | config: 74 | host: localhost 75 | port: 7711 76 | ''' 77 | -------------------------------------------------------------------------------- /sauna/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Daemon that runs and reports health checks 3 | 4 | Documentation https://sauna.readthedocs.io 5 | 6 | Usage: 7 | sauna [--level=] [--config=FILE] [ ...] 8 | sauna sample 9 | sauna (-h | --help) 10 | sauna --version 11 | 12 | Options: 13 | -h --help Show this screen. 14 | --version Show version. 15 | --level= Log level [default: warn]. 16 | --config=FILE Config file [default: sauna.yml]. 17 | 18 | Available commands: 19 | """ 20 | import sys 21 | import logging 22 | import logging.config 23 | 24 | from docopt import docopt, DocoptLanguageError 25 | from yaml.error import YAMLError 26 | 27 | import sauna 28 | from sauna import commands 29 | 30 | 31 | def build_main_doc(): 32 | sauna.Sauna.import_submodules('sauna.commands.ext') 33 | doc = __doc__ 34 | for name, func in sorted(commands.CommandRegister.all_commands.items()): 35 | summary = func.__doc__ .splitlines()[0] 36 | doc += ' {:<28} {}\n'.format(name, summary) 37 | return doc 38 | 39 | 40 | def main(): 41 | doc = build_main_doc() 42 | args = docopt(doc, version=sauna.__version__, options_first=True) 43 | conf_file = args['--config'] 44 | logging.basicConfig( 45 | format='%(asctime)s - %(levelname)-8s - %(name)s: %(message)s', 46 | datefmt='%Y/%m/%d %H:%M:%S', 47 | level=getattr(logging, args['--level'].upper(), 'WARN') 48 | ) 49 | 50 | # Sample command needs a not configured instance of sauna 51 | if args.get('') == 'sample': 52 | sauna_instance = sauna.Sauna() 53 | file_path = sauna_instance.assemble_config_sample('./') 54 | print('Created file {}'.format(file_path)) 55 | sys.exit(0) 56 | 57 | try: 58 | config = sauna.read_config(conf_file) 59 | except YAMLError as e: 60 | print('YAML syntax in configuration file {} is not valid: {}'. 61 | format(conf_file, e)) 62 | sys.exit(1) 63 | 64 | if 'logging' in config: 65 | # Override the logging configuration with the one from the config file 66 | logging.config.dictConfig(config['logging']) 67 | 68 | sauna_instance = sauna.Sauna(config) 69 | 70 | # Generic commands implemented in sauna.commands package 71 | if args.get(''): 72 | argv = [args['']] + args[''] 73 | try: 74 | func = commands.CommandRegister.all_commands[args['']] 75 | except KeyError: 76 | print('{} is not a valid command'.format(args[''])) 77 | sys.exit(1) 78 | try: 79 | command_args = docopt(func.__doc__, argv=argv) 80 | except DocoptLanguageError: 81 | command_args = None 82 | func(sauna_instance, command_args) 83 | 84 | # Just run sauna 85 | else: 86 | sauna_instance.launch() 87 | 88 | logging.shutdown() 89 | -------------------------------------------------------------------------------- /sauna/plugins/ext/http.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, PluginRegister 2 | 3 | my_plugin = PluginRegister('HTTP') 4 | 5 | 6 | @my_plugin.plugin() 7 | class HTTP(Plugin): 8 | 9 | def __init__(self, config): 10 | super().__init__(config) 11 | try: 12 | import requests 13 | self.requests = requests 14 | except ImportError: 15 | from ... import DependencyError 16 | raise DependencyError(self.__class__.__name__, 'requests', 17 | 'requests', 'python3-requests') 18 | 19 | @my_plugin.check() 20 | def request(self, check_config): 21 | code = check_config.get('code', 200) 22 | content = check_config.get('content', '') 23 | 24 | try: 25 | r = self._do_http_request(check_config) 26 | except Exception as e: 27 | return Plugin.STATUS_CRIT, '{}'.format(e) 28 | 29 | if r.status_code != code: 30 | return ( 31 | Plugin.STATUS_CRIT, 32 | 'Got status code {} instead of {}'.format(r.status_code, code) 33 | ) 34 | if content not in r.text: 35 | return ( 36 | Plugin.STATUS_CRIT, 37 | 'Content "{}" not in response'.format(content) 38 | ) 39 | elapsed_ms = int(r.elapsed.microseconds / 1000) 40 | return ( 41 | self._value_to_status_less(elapsed_ms, check_config), 42 | 'HTTP {} in {} ms'.format(r.status_code, elapsed_ms) 43 | ) 44 | 45 | def _do_http_request(self, check_config): 46 | method = check_config.get('method', 'GET').upper() 47 | timeout = check_config.get('timeout', 10000) / 1000 48 | verify_ca_crt = check_config.get('verify_ca_crt', True) 49 | data = check_config.get('data') 50 | json = check_config.get('json') 51 | headers = check_config.get('headers') 52 | params = check_config.get('params') 53 | auth = check_config.get('auth') 54 | cookies = check_config.get('cookies') 55 | allow_redirects = check_config.get('allow_redirects', True) 56 | url = check_config['url'] 57 | 58 | return self.requests.request(method, url, 59 | verify=verify_ca_crt, 60 | timeout=timeout, 61 | data=data, 62 | json=json, 63 | headers=headers, 64 | params=params, 65 | auth=auth, 66 | cookies=cookies, 67 | allow_redirects=allow_redirects) 68 | 69 | @staticmethod 70 | def config_sample(): 71 | return ''' 72 | # Make an HTTP request 73 | # timeout, warn and crit are durations in milliseconds 74 | - type: HTTP 75 | checks: 76 | - type: request 77 | url: https://www.website.tld 78 | verify_ca_crt: true 79 | method: GET 80 | code: 200 81 | content: Welcome! 82 | timeout: 5000 83 | warn: 1000 84 | crit: 5000 85 | ''' 86 | -------------------------------------------------------------------------------- /sauna/plugins/ext/network.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | import time 3 | 4 | from sauna.plugins.base import PsutilPlugin 5 | from sauna.plugins import human_to_bytes, bytes_to_human, PluginRegister 6 | 7 | my_plugin = PluginRegister('Network') 8 | 9 | 10 | @my_plugin.plugin() 11 | class Network(PsutilPlugin): 12 | 13 | def __init__(self, config): 14 | super().__init__(config) 15 | 16 | @my_plugin.check() 17 | def upload_data_speed(self, check_config): 18 | ul, _, _, _ = self.get_network_data( 19 | interface=check_config['interface']) 20 | ul = round(ul, 2) 21 | 22 | return ( 23 | self._value_to_status_less(ul, check_config, human_to_bytes), 24 | 'Upload speed: {}/s'.format(bytes_to_human(ul)) 25 | ) 26 | 27 | @my_plugin.check() 28 | def download_data_speed(self, check_config): 29 | _, dl, _, _ = self.get_network_data( 30 | interface=check_config['interface']) 31 | dl = round(dl, 2) 32 | return ( 33 | self._value_to_status_less(dl, check_config, human_to_bytes), 34 | 'Download speed: {}/s'.format(bytes_to_human(dl)) 35 | ) 36 | 37 | @my_plugin.check() 38 | def upload_packet_speed(self, check_config): 39 | _, _, ul, _ = self.get_network_data( 40 | interface=check_config['interface']) 41 | ul = round(ul, 2) 42 | return ( 43 | self._value_to_status_less(ul, check_config), 44 | 'Upload : {} p/s'.format(ul) 45 | ) 46 | 47 | @my_plugin.check() 48 | def download_packet_speed(self, check_config): 49 | _, _, _, dl = self.get_network_data( 50 | interface=check_config['interface']) 51 | dl = round(dl, 2) 52 | return ( 53 | self._value_to_status_less(dl, check_config), 54 | 'Download : {} p/s'.format(dl) 55 | ) 56 | 57 | @lru_cache() 58 | def get_network_data(self, interface='eth0', delay=1): 59 | t0 = time.time() 60 | counter = self.psutil.net_io_counters(pernic=True)[interface] 61 | first_values = (counter.bytes_sent, counter.bytes_recv, 62 | counter.packets_sent, counter.packets_recv) 63 | 64 | time.sleep(delay) 65 | counter = self.psutil.net_io_counters(pernic=True)[interface] 66 | t1 = time.time() 67 | last_values = (counter.bytes_sent, counter.bytes_recv, 68 | counter.packets_sent, counter.packets_recv) 69 | kb_ul, kb_dl, p_ul, p_dl = [ 70 | (last - first) / (t1 - t0) 71 | for last, first in zip(last_values, first_values) 72 | ] 73 | return kb_ul, kb_dl, p_ul, p_dl 74 | 75 | @staticmethod 76 | def config_sample(): 77 | return ''' 78 | - type: Network 79 | 80 | checks: 81 | - type: upload_data_speed 82 | interface: em1 83 | # Crit if download > 2MB/s 84 | warn: 500K 85 | crit: 2M 86 | - type: download_data_speed 87 | interface: em1 88 | # Warn if upload > 500KB/s 89 | warn: 500K 90 | crit: 2M 91 | - type: upload_packet_speed 92 | interface: em1 93 | # Values are in packet/s 94 | warn: 500 95 | crit: 2000 96 | - type: download_packet_speed 97 | interface: em1 98 | # Values are in packet/s 99 | warn: 500 100 | crit: 2000 101 | ''' 102 | -------------------------------------------------------------------------------- /sauna/scheduler.py: -------------------------------------------------------------------------------- 1 | import time 2 | import math 3 | from functools import reduce 4 | from logging import getLogger 5 | 6 | logger = getLogger(__name__) 7 | 8 | 9 | class Scheduler: 10 | 11 | def __init__(self, jobs): 12 | """ 13 | Create a new Scheduler. 14 | 15 | >>> s = Scheduler([Job(1, max, 100, 200)]) 16 | >>> for jobs in s: 17 | ... time.sleep(s.tick_duration) 18 | 19 | :param jobs: Sequence of jobs to schedule 20 | """ 21 | periodicities = {job.periodicity for job in jobs} 22 | self.tick_duration = reduce(lambda x, y: math.gcd(x, y), 23 | periodicities) 24 | self._ticks = self.find_minimum_ticks_required(self.tick_duration, 25 | periodicities) 26 | self._jobs = jobs 27 | self._current_tick = 0 28 | logger.debug('Scheduler has {} ticks, each one is {} seconds'. 29 | format(self._ticks, self.tick_duration)) 30 | 31 | @staticmethod 32 | def find_minimum_ticks_required(tick_duration, periodicities): 33 | """Find the minimum number of ticks required to execute all jobs 34 | at once.""" 35 | ticks = 1 36 | for periodicity in reversed(sorted(periodicities)): 37 | if ticks % periodicity != 0: 38 | ticks *= int(periodicity / tick_duration) 39 | return ticks 40 | 41 | def __iter__(self): 42 | return self 43 | 44 | def __next__(self): 45 | jobs = [job for job in self._jobs 46 | if ((self._current_tick * self.tick_duration) 47 | % job.periodicity) == 0 48 | ] 49 | if jobs: 50 | logger.debug('Tick {}, scheduled {}'. 51 | format(self._current_tick, jobs)) 52 | self._current_tick += 1 53 | if self._current_tick >= self._ticks: 54 | self._current_tick = 0 55 | for job in jobs: 56 | job() 57 | return jobs 58 | 59 | def run(self): 60 | """Shorthand for iterating over all jobs forever. 61 | 62 | >>> print_time = lambda: print(time.time()) 63 | >>> s = Scheduler([Job(1, print_time)]) 64 | >>> s.run() 65 | 1470146095.0748773 66 | 1470146096.076028 67 | """ 68 | for _ in self: 69 | time.sleep(self.tick_duration) 70 | 71 | 72 | class Job: 73 | 74 | def __init__(self, periodicity, func, *func_args, **func_kwargs): 75 | """ 76 | Create a new Job to be scheduled and run periodically. 77 | 78 | :param periodicity: Number of seconds to wait between job runs 79 | :param func: callable that perform the job action 80 | :param func_args: arguments of the callable 81 | :param func_kwargs: keyword arguments of the callable 82 | """ 83 | if not callable(func): 84 | raise ValueError('func attribute must be callable') 85 | self.periodicity = periodicity 86 | self.func = func 87 | self.func_args = func_args 88 | self.func_kwargs = func_kwargs 89 | 90 | def __repr__(self): 91 | try: 92 | name = self.func.__name__ 93 | except AttributeError: 94 | name = 'unknown' 95 | return ''.format(name, 96 | self.periodicity) 97 | 98 | def __call__(self, *args, **kwargs): 99 | self.func(*self.func_args, **self.func_kwargs) 100 | -------------------------------------------------------------------------------- /sauna/plugins/ext/postfix.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import re 3 | import subprocess 4 | 5 | from sauna.plugins import Plugin 6 | from sauna.plugins import PluginRegister 7 | 8 | my_plugin = PluginRegister('Postfix') 9 | 10 | 11 | @my_plugin.plugin() 12 | class Postfix(Plugin): 13 | 14 | def __init__(self, config): 15 | super().__init__(config) 16 | self.config = { 17 | 'host': config.get('host', 'localhost'), 18 | 'port': config.get('port', 4280), 19 | 'timeout': config.get('timeout', 5), 20 | 'method': config.get('method', 'mailq') 21 | } 22 | self._mailq_output = None 23 | 24 | @my_plugin.check() 25 | def queue_size(self, check_config): 26 | queue_size = self._get_queue_size() 27 | return (self._value_to_status_less(queue_size, check_config), 28 | '{} mail(s) in queue'.format(queue_size)) 29 | 30 | @property 31 | def mailq_output(self): 32 | if not self._mailq_output: 33 | if self.config['method'] == 'tcp': 34 | self._mailq_output = self._fetch_showq() 35 | else: 36 | self._mailq_output = self._exec_mailq_command() 37 | return self._mailq_output 38 | 39 | def _get_queue_size(self): 40 | if 'Mail queue is empty' in self.mailq_output: 41 | return 0 42 | if 'mail system is down' in self.mailq_output: 43 | raise Exception('Cannot get queue size: {}'. 44 | format(self.mailq_output)) 45 | match = re.search( 46 | r'^-- \d+ [GMK]bytes in (\d+) Requests?\.$', 47 | self.mailq_output, 48 | re.MULTILINE 49 | ) 50 | if not match: 51 | raise Exception('Cannot parse mailq') 52 | return int(match.group(1)) 53 | 54 | def _fetch_showq(self): 55 | """Connect to Postfix showq inet daemon and retrieve queue. 56 | 57 | This method is faster than executing mailq because it doesn't fork 58 | processes. 59 | It requires to have showq inet daemon activated which is not the case 60 | by default. To make showq listen on the loopback interface on port 61 | 4280, add to your master.cf: 62 | 127.0.0.1:4280 inet n - - - - showq 63 | """ 64 | showq = bytes() 65 | with socket.create_connection((self.config['host'], 66 | self.config['port']), 67 | timeout=self.config['timeout']) as s: 68 | while True: 69 | buffer = bytearray(4096) 70 | bytes_received = s.recv_into(buffer) 71 | if bytes_received == 0: 72 | break 73 | showq += buffer 74 | return showq.decode(encoding='utf-8') 75 | 76 | def _exec_mailq_command(self): 77 | """Execute mailq command to communicate with showq daemon. 78 | 79 | mailq invokes postqueue with a setuid bit that grant it access to the 80 | Unix socket of showq daemon. 81 | """ 82 | p = subprocess.Popen( 83 | 'mailq', 84 | stdout=subprocess.PIPE, 85 | stderr=subprocess.STDOUT, 86 | universal_newlines=True 87 | ) 88 | stdout, _ = p.communicate(timeout=self.config['timeout']) 89 | return stdout 90 | 91 | @staticmethod 92 | def config_sample(): 93 | return ''' 94 | # Postfix queue 95 | - type: Postfix 96 | checks: 97 | - type: queue_size 98 | warn: 5 99 | crit: 10 100 | ''' 101 | -------------------------------------------------------------------------------- /sauna/plugins/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | 4 | 5 | class Plugin: 6 | """Base class to implement check plugins. 7 | 8 | Most methods use a check_config dict that mostly defines thresholds. 9 | It looks like: 10 | check_config = { 11 | 'warn': '80%', 12 | 'crit': '90%', 13 | 'other_data': 'something' 14 | } 15 | """ 16 | 17 | STATUS_OK = 0 18 | STATUS_WARN = 1 19 | STATUS_CRIT = 2 20 | STATUS_UNKNOWN = 3 21 | 22 | def __init__(self, config): 23 | if config is None: 24 | config = {} 25 | self.config = config 26 | 27 | @property 28 | def logger(self): 29 | return logging.getLogger('sauna.' + self.__class__.__name__) 30 | 31 | @classmethod 32 | def get_thresholds(cls, check_config, modifier=None): 33 | critical_threshold = check_config['crit'] 34 | if modifier: 35 | critical_threshold = modifier(critical_threshold) 36 | warning_threshold = check_config['warn'] 37 | if modifier: 38 | warning_threshold = modifier(warning_threshold) 39 | return critical_threshold, warning_threshold 40 | 41 | @classmethod 42 | def _value_to_status_less(cls, value, check_config, modifier=None): 43 | """Return an error when the value should be less than threshold.""" 44 | critical, warning = cls.get_thresholds(check_config, modifier) 45 | if value >= critical: 46 | return cls.STATUS_CRIT 47 | elif value >= warning: 48 | return cls.STATUS_WARN 49 | else: 50 | return cls.STATUS_OK 51 | 52 | @classmethod 53 | def _value_to_status_more(cls, value, check_config, modifier=None): 54 | """Return an error when the value should be more than threshold.""" 55 | critical, warning = cls.get_thresholds(check_config, modifier) 56 | if value <= critical: 57 | return cls.STATUS_CRIT 58 | elif value <= warning: 59 | return cls.STATUS_WARN 60 | else: 61 | return cls.STATUS_OK 62 | 63 | @classmethod 64 | def _strip_percent_sign(cls, value): 65 | try: 66 | return float(value) 67 | except ValueError: 68 | return float(value.split('%')[0]) 69 | 70 | @classmethod 71 | def _strip_percent_sign_from_check_config(cls, check_config): 72 | check_config = copy.deepcopy(check_config) 73 | check_config['warn'] = cls._strip_percent_sign(check_config['warn']) 74 | check_config['crit'] = cls._strip_percent_sign(check_config['crit']) 75 | return check_config 76 | 77 | @classmethod 78 | def status_code_to_str(cls, status_code): 79 | if status_code == Plugin.STATUS_OK: 80 | return 'OK' 81 | elif status_code == Plugin.STATUS_WARN: 82 | return 'WARNING' 83 | elif status_code == Plugin.STATUS_CRIT: 84 | return 'CRITICAL' 85 | else: 86 | return 'UNKNOWN' 87 | 88 | 89 | class Check: 90 | def __init__(self, name, periodicity, check_func, config): 91 | self.name = name 92 | self.periodicity = periodicity 93 | self.check_func = check_func 94 | self.config = config 95 | 96 | def run_check(self): 97 | return self.check_func(self.config) 98 | 99 | 100 | class PsutilPlugin(Plugin): 101 | 102 | def __init__(self, config): 103 | super().__init__(config) 104 | try: 105 | import psutil 106 | self.psutil = psutil 107 | except ImportError: 108 | from .. import DependencyError 109 | raise DependencyError(self.__class__.__name__, 'psutil', 110 | 'psutil', 'python3-psutil') 111 | -------------------------------------------------------------------------------- /sauna/plugins/ext/hwmon.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | from collections import namedtuple 4 | from functools import reduce 5 | 6 | from sauna.plugins import Plugin, PluginRegister 7 | 8 | 9 | Sensor = namedtuple('Sensor', ['device_name', 'label', 'value']) 10 | 11 | my_plugin = PluginRegister('Hwmon') 12 | 13 | 14 | @my_plugin.plugin() 15 | class Hwmon(Plugin): 16 | """Linux hardware monitoring plugin. 17 | 18 | This plugin crawls Linux's /sys/class/hwmon to find usable sensors. Be 19 | warned that this method is quite fragile since exotic hardware may present 20 | values that need offsets or conversions. 21 | 22 | A more solid approach could be to use lm-sensors, but: 23 | - it requires to install and configure lm-sensors 24 | - there is no proper python bindings to the library 25 | - parsing the output of 'sensors' is not fun nor efficient 26 | """ 27 | 28 | @my_plugin.check() 29 | def temperature(self, check_config): 30 | dummy_sensor = Sensor(device_name='Dummy', label='Dummy', value=-1000) 31 | sensors = self._get_temperatures() 32 | if check_config.get('sensors'): 33 | sensors = [ 34 | sensor for sensor in sensors 35 | if sensor.device_name in check_config.get('sensors', []) 36 | ] 37 | sensor = reduce(lambda x, y: x if x.value > y.value else y, 38 | sensors, 39 | dummy_sensor) 40 | if sensor is dummy_sensor: 41 | return self.STATUS_UNKNOWN, 'No sensor found' 42 | status = self._value_to_status_less(sensor.value, check_config) 43 | if status > self.STATUS_OK: 44 | return ( 45 | status, 46 | 'Sensor {}/{} {}°C'.format(sensor.device_name, 47 | sensor.label, 48 | sensor.value) 49 | ) 50 | return self.STATUS_OK, 'Temperature okay ({}°C)'.format(sensor.value) 51 | 52 | @classmethod 53 | def _get_temperatures(cls): 54 | temperatures = list() 55 | for device in cls._get_devices(): 56 | temperatures.extend(cls._process_device(device)) 57 | return temperatures 58 | 59 | @classmethod 60 | def _get_devices(cls): 61 | base_path = '/sys/class/hwmon' 62 | devices = set() 63 | for file in os.listdir(base_path): 64 | file_path = os.path.join(base_path, file) 65 | if os.path.isfile(os.path.join(file_path, 'device', 'name')): 66 | devices.add(os.path.join(file_path, 'device')) 67 | else: 68 | devices.add(file_path) 69 | return devices 70 | 71 | @classmethod 72 | def _process_device(cls, device): 73 | sensors = list() 74 | with open(os.path.join(device, 'name')) as f: 75 | device_name = f.read().strip() 76 | pattern = 'temp*_input' 77 | for temp_file in glob.glob(os.path.join(device, pattern)): 78 | with open(temp_file) as f: 79 | temperature = int(int(f.read().strip())/1000) 80 | try: 81 | with open(temp_file.replace('input', 'label')) as f: 82 | label = f.read().strip() 83 | except (OSError, IOError): 84 | label = None 85 | 86 | sensors.append(Sensor(device_name, label, temperature)) 87 | 88 | return sensors 89 | 90 | @staticmethod 91 | def config_sample(): 92 | return ''' 93 | # Linux hardware monitoring 94 | - type: Hwmon 95 | checks: 96 | # Raise an alert if any sensor gets beyond a threshold 97 | - type: temperature 98 | warn: 65 99 | crit: 85 100 | # Optionally, only check some sensors 101 | sensors: ['acpitz', 'coretemp'] 102 | ''' 103 | -------------------------------------------------------------------------------- /debian/sauna.init: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: sauna 4 | # Required-Start: $remote_fs $syslog 5 | # Required-Stop: $remote_fs $syslog 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: Sauna health check daemon 9 | # Description: Starts sauna 10 | ### END INIT INFO 11 | 12 | 13 | # PATH should only include /usr/* if it runs after the mountnfs.sh script 14 | PATH=/sbin:/usr/sbin:/bin:/usr/bin 15 | DESC="Sauna health check daemon" 16 | NAME=sauna 17 | DAEMON=/usr/bin/$NAME 18 | DAEMON_ARGS="--config /etc/sauna.yml" 19 | PIDFILE=/var/run/$NAME.pid 20 | SCRIPTNAME=/etc/init.d/$NAME 21 | LOGFILE=/var/log/$NAME.log 22 | 23 | # Exit if the package is not installed 24 | [ -x "$DAEMON" ] || exit 0 25 | 26 | # Read configuration variable file if it is present 27 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME 28 | 29 | # Load the VERBOSE setting and other rcS variables 30 | . /lib/init/vars.sh 31 | 32 | # Define LSB log_* functions. 33 | # Depend on lsb-base (>= 3.2-14) to ensure that this file is present 34 | # and status_of_proc is working. 35 | . /lib/lsb/init-functions 36 | 37 | # 38 | # Function that starts the daemon/service 39 | # 40 | do_start() 41 | { 42 | # Return 43 | # 0 if daemon has been started 44 | # 1 if daemon was already running 45 | # 2 if daemon could not be started 46 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ 47 | || return 1 48 | start-stop-daemon --start --background --make-pidfile --quiet --no-close \ 49 | --chuid sauna:sauna --pidfile $PIDFILE --exec $DAEMON -- \ 50 | $DAEMON_ARGS >>$LOGFILE 2>&1 \ 51 | || return 2 52 | } 53 | 54 | # 55 | # Function that stops the daemon/service 56 | # 57 | do_stop() 58 | { 59 | # Return 60 | # 0 if daemon has been stopped 61 | # 1 if daemon was already stopped 62 | # 2 if daemon could not be stopped 63 | # other if a failure occurred 64 | start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE 65 | if [ $? -ne 0 ] ; then 66 | echo "Could not stop Sauna" 67 | else 68 | echo "Stopped sauna" 69 | fi 70 | rm -f $PID_FILE 71 | 72 | } 73 | 74 | # 75 | # Function that sends a SIGHUP to the daemon/service 76 | # 77 | do_reload() { 78 | # 79 | # If the daemon can reload its configuration without 80 | # restarting (for example, when it is sent a SIGHUP), 81 | # then implement that here. 82 | # 83 | #start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE 84 | return 0 85 | } 86 | 87 | case "$1" in 88 | start) 89 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" 90 | do_start 91 | case "$?" in 92 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 93 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 94 | esac 95 | ;; 96 | stop) 97 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 98 | do_stop 99 | case "$?" in 100 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 101 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 102 | esac 103 | ;; 104 | status) 105 | status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? 106 | ;; 107 | #reload|force-reload) 108 | # 109 | # If do_reload() is not implemented then leave this commented out 110 | # and leave 'force-reload' as an alias for 'restart'. 111 | # 112 | #log_daemon_msg "Reloading $DESC" "$NAME" 113 | #do_reload 114 | #log_end_msg $? 115 | #;; 116 | restart|force-reload) 117 | # 118 | # If the "reload" option is implemented then remove the 119 | # 'force-reload' alias 120 | # 121 | log_daemon_msg "Restarting $DESC" "$NAME" 122 | do_stop 123 | case "$?" in 124 | 0|1) 125 | do_start 126 | case "$?" in 127 | 0) log_end_msg 0 ;; 128 | 1) log_end_msg 1 ;; # Old process is still running 129 | *) log_end_msg 1 ;; # Failed to start 130 | esac 131 | ;; 132 | *) 133 | # Failed to stop 134 | log_end_msg 1 135 | ;; 136 | esac 137 | ;; 138 | *) 139 | #echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2 140 | echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 141 | exit 3 142 | ;; 143 | esac 144 | 145 | : 146 | -------------------------------------------------------------------------------- /sauna/plugins/ext/http_json.py: -------------------------------------------------------------------------------- 1 | from sauna.plugins import Plugin, PluginRegister 2 | from sauna.plugins.ext.http import HTTP 3 | from sauna import DependencyError 4 | import re 5 | import json 6 | 7 | my_plugin = PluginRegister('HTTP-JSON') 8 | 9 | 10 | @my_plugin.plugin() 11 | class HTTPJSON(HTTP): 12 | def __init__(self, config): 13 | super().__init__(config) 14 | try: 15 | import jsonpath_rw as jsonpath 16 | self.jsonpath = jsonpath 17 | except ImportError: 18 | raise DependencyError(self.__class__.__name__, 'jsonpath_rw', 19 | pypi='jsonpath-rw') 20 | 21 | @my_plugin.check() 22 | def request(self, check_config): 23 | code = check_config.get('code', 200) 24 | expect = check_config.get('expect', None) 25 | 26 | try: 27 | r = self._do_http_request(check_config) 28 | except Exception as e: 29 | return Plugin.STATUS_CRIT, '{}'.format(e) 30 | 31 | if r.status_code != code: 32 | return ( 33 | Plugin.STATUS_CRIT, 34 | self._error_message( 35 | 'Got status code {} instead of {}'.format( 36 | r.status_code, code), 37 | r, check_config) 38 | ) 39 | 40 | if expect is not None: 41 | regex = re.compile(expect) 42 | if 'success_jsonpath' in check_config: 43 | finder = self.jsonpath.parse(check_config['success_jsonpath']) 44 | try: 45 | data = json.loads(r.text) 46 | except ValueError as ex: 47 | return ( 48 | Plugin.STATUS_CRIT, 49 | self._error_message( 50 | 'Fail to parse response as JSON: {}'.format(ex), 51 | r, check_config) 52 | ) 53 | matches = finder.find(data) 54 | found = any(regex.match(str(match.value)) for match in matches) 55 | else: 56 | found = bool(regex.match(r.text)) 57 | 58 | if not found: 59 | return ( 60 | Plugin.STATUS_CRIT, 61 | self._error_message( 62 | 'Could not find expected result ({})'.format(expect), 63 | r, check_config) 64 | ) 65 | 66 | elapsed_ms = int(r.elapsed.microseconds / 1000) 67 | return ( 68 | self._value_to_status_less(elapsed_ms, check_config), 69 | 'HTTP {} in {} ms'.format(r.status_code, elapsed_ms) 70 | ) 71 | 72 | def _error_message(self, msg, r, check_config): 73 | error_jsonpath = check_config.get('error_jsonpath', None) 74 | if error_jsonpath is None: 75 | return msg 76 | try: 77 | data = json.loads(r.text) 78 | except ValueError: 79 | return msg 80 | finder = self.jsonpath.parse(error_jsonpath) 81 | matches = finder.find(data) 82 | for m in matches: 83 | msg += ', {}: {}'.format(m.path, m.value) 84 | return msg 85 | 86 | @staticmethod 87 | def config_sample(): 88 | return ''' 89 | # Make an HTTP request and parse the result as JSON 90 | # timeout, warn and crit are durations in milliseconds 91 | # If success_jsonpath is undefined, the whole response 92 | # is matched against the expect regex 93 | # If error_jsonpath is defined, all matches within the 94 | # response are returned with the check result 95 | - type: HTTP-JSON 96 | checks: 97 | - type: request 98 | url: https://www.website.tld/status 99 | verify_ca_crt: false 100 | method: GET 101 | code: 200 102 | success_jsonpath: '$.status' 103 | expect: (ok|OK) 104 | error_jsonpath: '$.message' 105 | timeout: 5000 106 | warn: 1000 107 | crit: 5000 108 | ''' 109 | -------------------------------------------------------------------------------- /doc/user/cookbook.rst: -------------------------------------------------------------------------------- 1 | .. _cookbook: 2 | 3 | Cookbook 4 | ======== 5 | 6 | This page provides recipes to get the best out of sauna. 7 | 8 | HAProxy health checks 9 | --------------------- 10 | 11 | When load balancing servers with HAProxy you might want to enable health checks. If one of your 12 | servers is running out of memory, overloaded or not behaving properly you can remove it from the 13 | pool of healthy servers. 14 | 15 | To help you achieve that sauna has a special consumer that listens on a TCP port and returns the 16 | status of the server when getting an incoming connection. This consumer is the ``TCPServer`` 17 | consumer. 18 | 19 | Enabling the TCP server 20 | ~~~~~~~~~~~~~~~~~~~~~~~ 21 | 22 | To enable the TCP server add it to the list of :ref:`active consumers `:: 23 | 24 | --- 25 | consumers: 26 | 27 | TCPServer: 28 | port: 5555 29 | 30 | Let's launch sauna and try to connect to the port 5555:: 31 | 32 | $ nc localhost 5555 33 | OK 34 | 35 | As the system is healthy sauna answers ``OK``. Let's try by switching a check to ``CRITICAL``:: 36 | 37 | $ nc localhost 5555 38 | CRITICAL 39 | 40 | Configuring HAProxy 41 | ~~~~~~~~~~~~~~~~~~~ 42 | 43 | We will configure HAProxy to remove a server from the pool as soon as it is not in ``OK`` state. 44 | For that we will use `tcp-check 45 | `_. 46 | 47 | Assuming you have a load balancing frontend/backend already set up, activate checks:: 48 | 49 | backend webfarm 50 | mode http 51 | option tcp-check 52 | tcp-check connect port 5555 53 | tcp-check expect string OK 54 | server web01 10.0.0.1:80 check 55 | server web02 10.0.0.2:80 check 56 | server web03 10.0.0.3:80 check 57 | 58 | * ``option tcp-check`` enables level 3 health checks 59 | * ``tcp-check connect port 5555`` tells HAProxy to check the port 5555 of servers in the pool 60 | * ``tcp-check expect string OK`` consider the server down if it does not answer ``OK`` 61 | 62 | .. _nagios: 63 | 64 | Reusing Nagios plugins 65 | ---------------------- 66 | 67 | Nagios plugins are still very popular, their simple API can be considered the de facto standard for 68 | monitoring checks. Sauna can run Nagios plugins through its ``Command`` plugin. 69 | 70 | Here we will run the famous ``check_http`` for monitoring Google. Add a ``Command`` plugin to 71 | :ref:`sauna.yml `:: 72 | 73 | --- 74 | plugins: 75 | 76 | - type: Command 77 | checks: 78 | - type: command 79 | name: check_google 80 | command: /usr/lib/nagios/plugins/check_http -H www.google.com 81 | 82 | Run sauna:: 83 | 84 | $ sauna 85 | ServiceCheck(name='check_google', status=0, output='HTTP OK: HTTP/1.1 302 Found') 86 | 87 | .. note:: Nagios plugins may be convenient but they rely on forking a process for each check. 88 | Consider using some of the lighter sauna core plugins if this is an issue. 89 | 90 | Passive host checks 91 | ------------------- 92 | 93 | When it is not possible to check if a host is alive by sending a ping (for instance when the host 94 | is in a private network), Nagios and Shinken can use passive host checks submitted via NSCA. 95 | 96 | Passive host checks work like normal service checks, except that they don't carry a service name:: 97 | 98 | --- 99 | plugins: 100 | 101 | - type: Dummy 102 | checks: 103 | - type: dummy 104 | name: "" 105 | status: 0 106 | output: Host is up and running 107 | 108 | Configure your monitoring server to consider your host down if no passive host check has been 109 | received for one minute:: 110 | 111 | define host { 112 | address 192.168.20.3 113 | host_name test 114 | use generic-host 115 | check_command check_dummy!2 116 | active_checks_enabled 0 117 | passive_checks_enabled 1 118 | check_freshness 1 119 | freshness_threshold 60 120 | } 121 | -------------------------------------------------------------------------------- /sauna/consumers/ext/http_server/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http.server import HTTPServer, BaseHTTPRequestHandler 3 | from logging import getLogger 4 | 5 | from sauna.consumers.base import AsyncConsumer 6 | from sauna.consumers import ConsumerRegister 7 | from sauna import __version__ 8 | 9 | logger = getLogger('sauna.HTTPServerConsumer') 10 | my_consumer = ConsumerRegister('HTTPServer') 11 | 12 | 13 | class StoppableHTTPServer(HTTPServer): 14 | """HTTPServer that stops itself when receiving a threading.Event""" 15 | 16 | def __init__(self, must_stop, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._must_stop = must_stop 19 | 20 | def service_actions(self): 21 | """Called by the serve_forever() loop. 22 | Check if Sauna requested the server to shutdown. 23 | It cannot call self.shutdown() because the server does not run in 24 | a separated thread. 25 | """ 26 | if self._must_stop.is_set(): 27 | self._BaseServer__shutdown_request = True 28 | 29 | 30 | @my_consumer.consumer() 31 | class HTTPServerConsumer(AsyncConsumer): 32 | 33 | def __init__(self, config): 34 | super().__init__(config) 35 | self.config = { 36 | 'port': config.get('port', 8080), 37 | 'data_type': config.get('data_type', 'json'), 38 | 'address': config.get('address', '') # listen on all interfaces 39 | } 40 | 41 | def run(self, must_stop, *args): 42 | http_server = StoppableHTTPServer( 43 | must_stop, 44 | (self.config['address'], self.config['port']), 45 | self.HandlerFactory() 46 | ) 47 | http_server.serve_forever() 48 | self.logger.debug('Exited consumer thread') 49 | 50 | @staticmethod 51 | def config_sample(): 52 | return ''' 53 | # HTTP Server that exposes sauna status 54 | # as a REST API or a web dashboard 55 | - type: HTTPServer 56 | port: 8080 57 | data_type: json # Can be json or html 58 | ''' 59 | 60 | def HandlerFactory(self): 61 | config = self.config 62 | 63 | class Handler(BaseHTTPRequestHandler): 64 | 65 | server_version = 'Sauna/' + __version__ 66 | 67 | def do_GET(self): 68 | data = self.generate_response() 69 | self.wfile.write(data) 70 | 71 | def do_HEAD(self): 72 | self.generate_response() 73 | 74 | def generate_response(self): 75 | try: 76 | content = self.get_content_from_path() 77 | code = 200 78 | except NotFoundError: 79 | content = {'error': 'Resource not found'} 80 | code = 404 81 | 82 | self.send_response(code) 83 | if config['data_type'] == 'json': 84 | self.send_header('Content-Type', 'application/json') 85 | data = json.dumps(content).encode() 86 | elif config['data_type'] == 'html': 87 | self.send_header('Content-Type', 'text/html') 88 | from .html import get_html 89 | data = get_html() 90 | else: 91 | data = 'data type not found'.encode() 92 | self.send_header('Content-Length', len(data)) 93 | self.end_headers() 94 | return data 95 | 96 | def get_content_from_path(self): 97 | if self.path == '/': 98 | status, code = HTTPServerConsumer.get_current_status() 99 | return { 100 | 'status': status, 101 | 'code': code, 102 | 'checks': HTTPServerConsumer.get_checks_as_dict() 103 | } 104 | else: 105 | raise NotFoundError() 106 | 107 | def log_message(self, format, *args): 108 | logger.debug( 109 | '{} {}'.format(self.address_string(), format % args)) 110 | 111 | return Handler 112 | 113 | 114 | class NotFoundError(Exception): 115 | pass 116 | -------------------------------------------------------------------------------- /sauna/plugins/ext/memcached.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | 4 | from sauna.plugins import (Plugin, bytes_to_human, human_to_bytes, 5 | PluginRegister) 6 | 7 | my_plugin = PluginRegister('Memcached') 8 | 9 | 10 | @my_plugin.plugin() 11 | class Memcached(Plugin): 12 | 13 | def __init__(self, config): 14 | super().__init__(config) 15 | self.config = { 16 | 'host': config.get('host', 'localhost'), 17 | 'port': config.get('port', 11211), 18 | 'timeout': config.get('timeout', 5) 19 | } 20 | self._stats = None 21 | 22 | @my_plugin.check() 23 | def accepting_connections(self, check_config): 24 | try: 25 | accept_connections = self.stats['accepting_conns'] == 1 26 | except OSError as e: 27 | return (Plugin.STATUS_CRIT, 28 | 'Memcached is not accepting connections: {}'.format(e)) 29 | if accept_connections: 30 | return Plugin.STATUS_OK, 'Memcached is accepting connections' 31 | else: 32 | return Plugin.STATUS_CRIT, 'Memcached is not accepting connections' 33 | 34 | @my_plugin.check() 35 | def bytes(self, check_config): 36 | status = self._value_to_status_less(self.stats['bytes'], check_config, 37 | human_to_bytes) 38 | output = 'Memcached memory: {}'.format( 39 | bytes_to_human(self.stats['bytes']) 40 | ) 41 | return status, output 42 | 43 | @my_plugin.check() 44 | def used_percent(self, check_config): 45 | used_percent = int( 46 | self.stats['bytes'] * 100 / self.stats['limit_maxbytes'] 47 | ) 48 | status = self._value_to_status_less(used_percent, check_config, 49 | self._strip_percent_sign) 50 | output = 'Memcached memory used: {}% of {}'.format( 51 | used_percent, bytes_to_human(self.stats['limit_maxbytes']) 52 | ) 53 | return status, output 54 | 55 | @my_plugin.check() 56 | def current_items(self, check_config): 57 | status = self._value_to_status_less(self.stats['curr_items'], 58 | check_config) 59 | output = 'Memcached holds {} items'.format(self.stats['curr_items']) 60 | return status, output 61 | 62 | @property 63 | def stats(self): 64 | if not self._stats: 65 | self._stats = self._raw_stats_to_dict( 66 | self._fetch_memcached_stats() 67 | ) 68 | return self._stats 69 | 70 | @classmethod 71 | def _raw_stats_to_dict(cls, stats_data): 72 | """Convert raw memcached output to a dict of stats.""" 73 | stats_string = stats_data.decode('ascii') 74 | stats_string = stats_string.replace('\r\n', '\n') 75 | matches = re.findall(r'^STAT (\w+) (\d+)$', stats_string, 76 | flags=re.MULTILINE) 77 | return {match[0]: int(match[1]) for match in matches} 78 | 79 | def _fetch_memcached_stats(self): 80 | """Connect to Memcached and retrieve stats.""" 81 | data = bytes() 82 | with socket.create_connection((self.config['host'], 83 | self.config['port']), 84 | timeout=self.config['timeout']) as s: 85 | s.sendall(b'stats\r\n') 86 | while True: 87 | buffer = bytearray(4096) 88 | bytes_received = s.recv_into(buffer) 89 | if bytes_received == 0: 90 | # Remote host closed connection 91 | break 92 | data += buffer 93 | if b'\r\nEND\r\n' in data: 94 | # End of the stats command 95 | break 96 | return data 97 | 98 | @staticmethod 99 | def config_sample(): 100 | return ''' 101 | # Memcached 102 | - type: Memcached 103 | checks: 104 | - type: bytes 105 | warn: 128M 106 | crit: 256M 107 | - type: used_percent 108 | warn: 80% 109 | crit: 90% 110 | - type: current_items 111 | warn: 10000 112 | crit: 20000 113 | - type: accepting_connections 114 | config: 115 | host: localhost 116 | port: 11211 117 | ''' 118 | -------------------------------------------------------------------------------- /doc/dev/internals.rst: -------------------------------------------------------------------------------- 1 | .. _internals: 2 | 3 | Internals 4 | ========= 5 | 6 | This page provides the basic information needed to start hacking sauna. It presents how it works 7 | inside and how the project is designed. 8 | 9 | Design choices 10 | -------------- 11 | 12 | Easy to install 13 | ~~~~~~~~~~~~~~~ 14 | 15 | Installing software written in Python can be confusing for some users. Getting a Python interpreter 16 | is not an issue but installing the code and its dependencies is. 17 | 18 | The Python ecosystem is great, so many high quality libraries are available for almost anything. 19 | While it is okay for a web application to require dozens of dependencies, it is not for a simple 20 | monitoring daemon that should run on any Unix box. 21 | 22 | Most of the times checks are really basics, they involve reading files, contacting APIs... Often 23 | these things can be done in one line of code using a dedicated library, or 10 using the standard 24 | library. The latter has the advantage of simplifying the life of the end user. 25 | 26 | Of course it does not mean that sauna has to do everything from scratch, sometimes it's fine to get 27 | a little help. For instance the `psutil `_ library is so 28 | convenient for retrieving system metrics that it would be foolish not to rely on it. On the other 29 | hand getting statistics from memcached is just a matter of opening a socket, sending a few bytes 30 | and reading the result. It probably does not justify adding an extra dependency that someone may 31 | have a hard time installing. 32 | 33 | Batteries included, but removable 34 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 35 | 36 | Sauna tries its best to provide a set of core plugins that are useful to system administrators. But 37 | for instance, a user not interested in monitoring system metrics should be able to opt-out of 38 | system plugins. This reduces the footprint of sauna and doesn't require to user to install external 39 | dependencies that he will never use. 40 | 41 | Python 3 only 42 | ~~~~~~~~~~~~~ 43 | 44 | Not supporting Python 2 simplifies the code base. 45 | 46 | Python 3 has been around for about 8 years, it is available on every distribution and starts to 47 | replace Python 2 as the default interpreter on some of them. Most of the libraries in the Python 48 | ecosystem are compatible with the version 3. 49 | 50 | Efficient 51 | ~~~~~~~~~ 52 | 53 | Sauna tries to consume as little resources as possible. Your server probably has more important 54 | things to do than checking itself. 55 | 56 | Very often monitoring tools rely on launching external processes to fetch metrics from other 57 | systems. How often have you seen a program firing a ``/bin/df -h`` and parsing the output to 58 | retrieve the disk usage? 59 | 60 | This puts pressure on the system which has to fork processes, allocate memory and handle context 61 | switches, while most of the times its possible to use a dedicated API to retrieve the information, 62 | in this case the ``/proc`` file system. 63 | 64 | Concurrency 65 | ----------- 66 | 67 | Main thread 68 | ~~~~~~~~~~~ 69 | 70 | The main thread is responsible for setting up the application, launching the workers, handling 71 | signals and tearing down the application. It creates one producer thread and some consumer threads. 72 | 73 | Producer 74 | ~~~~~~~~ 75 | The producer is really simple, it is a loop that creates instances of plugins, run the checks and 76 | goes to sleep until it needs to loop again. Check results are appended to the consumers' queues. 77 | 78 | To handle checks that don't run at the same interval, a simple scheduler tells which checks should 79 | be run each time the producer wakes up. 80 | 81 | Consumers 82 | ~~~~~~~~~ 83 | 84 | Consumers exits in two flavors: with and without a queue. Queued consumers are synchronous, when 85 | they receive a check in their queue they use it straight away. The :py:class:`NSCAConsumer`, for 86 | instance, gets a check and sends it to a monitoring server. 87 | 88 | Asynchronous consumers do not have a queue, instead when they need to know the status of a check, 89 | they read it in a shared dictionary containing the last instance of all checks. A good example is 90 | the :py:class:`TCPServerConsumer`, it waits until a client connects to read the statuses from the 91 | dictionary. 92 | 93 | Each consumer runs on its own thread to prevent one consumer from blocking another. 94 | 95 | Thread safety 96 | ~~~~~~~~~~~~~ 97 | 98 | Queues are instances of :py:class:`queue.Queue` which handles the locking behind the scenes. 99 | 100 | Asynchronous consumers must only access the ``check_results`` shared dictionary after acquiring a 101 | lock:: 102 | 103 | with check_results_lock: 104 | # do somethin with check_results 105 | 106 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | sauna (0.0.18-1) unstable; urgency=low 2 | 3 | * Plugin Network: improve execution time 4 | * Plugin HTTP Server: allow to serve a web dashboard 5 | * Command OVH Shinken: fix parsing of configuration 6 | 7 | -- Nicolas Le Manchet Wed, 07 Jun 2017 18:51:15 +0200 8 | 9 | sauna (0.0.17-1) unstable; urgency=low 10 | 11 | * Allow checks to run concurrently in multiple threads 12 | * Plugin HTTP-JSON: fix bug when expected value is not a string 13 | * New plugin SimpleDomain to monitor DNS entries 14 | * New plugin Network to monitor Linux network throughput 15 | * More flexible logging system 16 | 17 | -- Nicolas Le Manchet Tue, 01 Mar 2017 19:40:15 +0100 18 | 19 | sauna (0.0.16-1) unstable; urgency=low 20 | 21 | * New plugin Disque to monitor the Disque Redis fork 22 | * New plugin HTTP-JSON to monitor REST APIs 23 | * New consumer HTTP server 24 | * Allow to run many times the same consumer with different configuration 25 | * Various improvements 26 | 27 | -- Nicolas Le Manchet Thu, 20 Nov 2016 21:18:10 +0200 28 | 29 | sauna (0.0.15-1) unstable; urgency=low 30 | 31 | * Allow to send passive host checks via NSCA 32 | * New plugin Dummy 33 | 34 | -- Nicolas Le Manchet Thu, 25 Aug 2016 15:28:10 +0200 35 | 36 | sauna (0.0.14-1) unstable; urgency=low 37 | 38 | * New plugin Ntpd 39 | 40 | -- Nicolas Le Manchet Tue, 23 Aug 2016 11:48:35 +0200 41 | 42 | sauna (0.0.13-1) unstable; urgency=low 43 | 44 | * Allow to run checks with different frequencies 45 | * New command Status to display the current status of checks 46 | 47 | -- Nicolas Le Manchet Wed, 03 Aug 2016 14:30:58 +0200 48 | 49 | sauna (0.0.12-1) unstable; urgency=low 50 | 51 | * Plugin Redis: monitor length of lists 52 | * Consumer NSCA: fix packet size 53 | * New plugin MdStat to monitor Linux RAID arrays 54 | * Command register: add compatibility with requests 2.2 for Ubuntu 14.04 55 | 56 | -- Nicolas Le Manchet Thu, 28 Jul 2016 11:19:42 +0200 57 | 58 | sauna (0.0.11-1) unstable; urgency=low 59 | 60 | * New plugin TCP that establishes TCP connections 61 | 62 | -- Nicolas Le Manchet Tue, 12 Jul 2016 17:24:19 +0200 63 | 64 | sauna (0.0.10-1) unstable; urgency=low 65 | 66 | * New plugin Hwmon for Linux hardware monitoring 67 | * New command Register to interact with OVH Shinken as a Service API 68 | 69 | -- Nicolas Le Manchet Mon, 11 Jul 2016 14:56:21 +0200 70 | 71 | sauna (0.0.9-1) unstable; urgency=low 72 | 73 | * Plugin Redis: allow to pass arbitrary config parameters 74 | * Plugin Processes: create check for zombie processes 75 | * Consumer TCPServer: decrease some log levels 76 | 77 | -- Nicolas Le Manchet Tue, 28 Jun 2016 14:26:33 +0200 78 | 79 | sauna (0.0.8-1) unstable; urgency=low 80 | 81 | * Allow to include additional configuration files 82 | 83 | -- Nicolas Le Manchet Thu, 19 May 2016 15:21:56 +0200 84 | 85 | sauna (0.0.7-1) unstable; urgency=medium 86 | 87 | * Plugin HTTP: for HTTPS, allow to provide a custom certificate or disable 88 | SSL verification 89 | 90 | -- Nicolas Le Manchet Thu, 12 May 2016 16:36:35 +0200 91 | 92 | sauna (0.0.6-1) unstable; urgency=low 93 | 94 | * New configuration syntax of plugins, allows more flexibility 95 | * New plugin HTTP 96 | * Plugin Processes: improve running check to count instances of a process 97 | 98 | -- Nicolas Le Manchet Fri, 06 May 2016 11:33:52 +0200 99 | 100 | sauna (0.0.5-1) unstable; urgency=low 101 | 102 | * Allow users to load extra plugins from their filesystem 103 | * New plugin Memcached 104 | * Plugin Processes: new check to monitor file descriptors 105 | * Plugin Disk: new check to monitor inodes 106 | 107 | -- Nicolas Le Manchet Fri, 29 Apr 2016 16:03:49 +0200 108 | 109 | sauna (0.0.4-1) unstable; urgency=low 110 | 111 | * Allow to run multiple consumers in parallel 112 | * Plugins and consumers are auto-discovered using decorators 113 | * Improve NSCA consumer reliability in a HA setup 114 | * New consumer TCP server 115 | * New plugin Puppet agent 116 | * New plugin Postfix 117 | 118 | -- Nicolas Le Manchet Tue, 19 Apr 2016 17:00:33 +0200 119 | 120 | sauna (0.0.3-1) unstable; urgency=low 121 | 122 | * New upstream release 123 | * Allow to use NSCA encryption 1 (aka XOR) 124 | 125 | -- Nicolas Le Manchet Wed, 16 Mar 2016 11:46:39 +0100 126 | 127 | sauna (0.0.2-1) unstable; urgency=low 128 | 129 | * First packaged. 130 | 131 | -- Nicolas Le Manchet Wed, 24 Feb 2016 10:00:00 +0000 132 | -------------------------------------------------------------------------------- /sauna/consumers/ext/home_assistant_mqtt.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Optional, Set, List 4 | 5 | from sauna.consumers.base import BatchQueuedConsumer 6 | from sauna.consumers import ConsumerRegister 7 | 8 | my_consumer = ConsumerRegister('HomeAssistantMQTT') 9 | 10 | 11 | status_to_name = { 12 | 0: 'OK', 13 | 1: 'Warning', 14 | 2: 'Critical', 15 | 3: 'Unknown' 16 | } 17 | 18 | 19 | @my_consumer.consumer() 20 | class HomeAssistantMQTTConsumer(BatchQueuedConsumer): 21 | """Report checks to Home Assistant via MQTT. 22 | 23 | Uses the Home Assistant MQTT discovery functionnality to 24 | dynamically create sensors for each couple of host/check. 25 | The first time Sauna sees a new check for a host, it inserts 26 | a MQTT message on a well known topic that HA monitors with the 27 | configuration for the new sensor. Each host/check gets its own 28 | MQTT topic that HA starts subscribing to. 29 | 30 | Afterwards checks are just sent on their own topic. 31 | """ 32 | 33 | def __init__(self, config): 34 | super().__init__(config) 35 | try: 36 | import paho.mqtt.publish as mqtt_publish 37 | self.mqtt_publish = mqtt_publish 38 | except ImportError: 39 | from ... import DependencyError 40 | raise DependencyError(self.__class__.__name__, 'paho-mqtt', 41 | 'paho-mqtt', 'python3-paho-mqtt') 42 | self.config = { 43 | 'hostname': config.get('hostname', 'localhost'), 44 | 'port': config.get('port', 1883), 45 | 'auth': config.get('auth', None) 46 | } 47 | self._configured_checks: Set[str] = set() 48 | 49 | from ... import __version__ 50 | self._version = __version__ 51 | 52 | def _to_safe_str(self, text: str) -> str: 53 | return re.sub('[^0-9a-zA-Z]+', '_', text) 54 | 55 | def _get_check_discovery(self, service_check) -> Optional[dict]: 56 | safe_hostname = self._to_safe_str(service_check.hostname) 57 | safe_name = self._to_safe_str(service_check.name) 58 | unique_id = f"sauna_{safe_hostname}_{safe_name}" 59 | if unique_id in self._configured_checks: 60 | return None 61 | 62 | self._configured_checks.add(unique_id) 63 | state_topic = f"sauna/{safe_hostname}/{safe_name}/state" 64 | return { 65 | "topic": f"homeassistant/sensor/{unique_id}/config", 66 | "retain": True, 67 | "qos": 1, 68 | "payload": json.dumps({ 69 | "value_template": "{{ value_json.status }}", 70 | "state_topic": state_topic, 71 | "json_attributes_topic": state_topic, 72 | "device": { 73 | "identifiers": [service_check.hostname], 74 | "name": service_check.hostname, 75 | "manufacturer": "Sauna", 76 | "sw_version": f"Sauna {self._version}" 77 | }, 78 | "name": f"{service_check.hostname} {service_check.name}", 79 | "unique_id": unique_id, 80 | "platform": "mqtt" 81 | }) 82 | } 83 | 84 | def _send_batch(self, service_checks: list): 85 | msgs: List[dict] = list() 86 | pending_discovery_checks: Set[str] = set() 87 | 88 | for service_check in service_checks: 89 | 90 | safe_hostname = self._to_safe_str(service_check.hostname) 91 | safe_name = self._to_safe_str(service_check.name) 92 | unique_id = f"sauna_{safe_hostname}_{safe_name}" 93 | 94 | discovery_msg = self._get_check_discovery(service_check) 95 | if discovery_msg is not None: 96 | msgs.append(discovery_msg) 97 | pending_discovery_checks.add(unique_id) 98 | 99 | msgs.append({ 100 | 'topic': f"sauna/{safe_hostname}/{safe_name}/state", 101 | 'retain': False, 102 | 'qos': 0, 103 | 'payload': json.dumps({ 104 | "status": status_to_name.get(service_check.status), 105 | "output": service_check.output 106 | }) 107 | }) 108 | 109 | try: 110 | self.mqtt_publish.multiple( 111 | msgs=msgs, 112 | hostname=self.config['hostname'], 113 | port=self.config['port'], 114 | auth=self.config['auth'] 115 | ) 116 | except Exception: 117 | # Remove the discovery message that could not be sent so that 118 | # they will be recreated the next time they are seen. 119 | self._configured_checks.difference_update(pending_discovery_checks) 120 | raise 121 | 122 | @staticmethod 123 | def config_sample(): 124 | return ''' 125 | # Report checks to Home Assistant via MQTT 126 | - type: HomeAssistantMQTT 127 | hostname: localhost 128 | port: 1883 129 | auth: 130 | username: user 131 | password: pass 132 | ''' 133 | -------------------------------------------------------------------------------- /doc/dev/custom.rst: -------------------------------------------------------------------------------- 1 | .. _custom: 2 | 3 | Writing custom checks 4 | ===================== 5 | 6 | Sauna ships with its own plugins for standard system monitoring, but sometimes you need more. This 7 | guide is a quick tutorial to start writing your own Python plugins to extend sauna's checks. 8 | 9 | If writing Python is not an option, binaries written is any language can be run through the 10 | :ref:`Command plugin `. 11 | 12 | For the sake of learning we will create the Uptime plugin. It will contain a simple check that will 13 | alert you when the uptime for your machine is under a threshold. This could be used to get a 14 | notification when a server rebooted unexpectedly. 15 | 16 | Custom plugins directory 17 | ------------------------ 18 | 19 | Your custom plugins must live somewhere on your file system. Let's say ``/tmp/sauna_plugins``:: 20 | 21 | $ mkdir /tmp/sauna_plugins 22 | 23 | This directory will contain `Python modules `_, 24 | like our ``uptime.py``:: 25 | 26 | $ cd /tmp/sauna_plugins 27 | $ touch uptime.py 28 | 29 | The Uptime class 30 | ---------------- 31 | 32 | All plugins have in common the same Python class ``Plugin``, so the quite not working simplest 33 | implementation of a plugin is:: 34 | 35 | from sauna.plugins import Plugin 36 | 37 | class Uptime(Plugin): 38 | pass 39 | 40 | This implementation must be registered into sauna to be used to launch checks, as often in Python, 41 | this is done through a bit of decorator magic:: 42 | 43 | from sauna.plugins import Plugin, PluginRegister 44 | 45 | my_plugin = PluginRegister('Uptime') 46 | 47 | @my_plugin.plugin() 48 | class Uptime(Plugin): 49 | pass 50 | 51 | Here we are, a minimal class that is a sauna plugin. Now let's create our check. 52 | 53 | The uptime check 54 | ---------------- 55 | 56 | A check is simply a method of the Plugin that is marked as a check, again through a decorator:: 57 | 58 | 59 | @my_plugin.plugin() 60 | class Uptime(Plugin): 61 | 62 | @my_plugin.check() 63 | def uptime(self, check_config): 64 | return self.STATUS_OK, 'Uptime looks good' 65 | 66 | So far we have an ``Uptime`` plugin, with an ``uptime`` check that always returns a positive 67 | status. Here is a bit of convention about checks: they must return a tuple containing the status 68 | (okay, warning, critical or unknown) and a human readable string explaining the result. 69 | 70 | Arguably this check is not really useful, let's change that by actually fetching the uptime from 71 | ``/proc/uptime``:: 72 | 73 | @my_plugin.check() 74 | def uptime(self, check_config): 75 | with open('/proc/uptime') as f: 76 | uptime_seconds = float(f.read().split()[0]) 77 | return (self._value_to_status_more(uptime_seconds, check_config), 78 | 'Uptime is {}'.format(timedelta(seconds=uptime_seconds))) 79 | 80 | The ``check_config`` passed to your check method contains the information needed to run the 81 | check and generate a status, it contains for instance the warning and critical thresholds. The 82 | value of uptime in seconds can be compared to the threshold with ``_value_to_status_more``, which 83 | returns the correct status. 84 | 85 | If during the execution of the check an exception is thrown, for instance if the ``/proc`` file 86 | system is not available, the check result will have the status ``unknown``. 87 | 88 | The final plugin 89 | ---------------- 90 | 91 | All these snippets together give the final plugin code:: 92 | 93 | from datetime import timedelta 94 | 95 | from sauna.plugins import Plugin, PluginRegister 96 | 97 | my_plugin = PluginRegister('Uptime') 98 | 99 | @my_plugin.plugin() 100 | class Uptime(Plugin): 101 | 102 | @my_plugin.check() 103 | def uptime(self, check_config): 104 | with open('/proc/uptime') as f: 105 | uptime_seconds = float(f.read().split()[0]) 106 | return (self._value_to_status_more(uptime_seconds, check_config), 107 | 'Uptime is {}'.format(timedelta(seconds=uptime_seconds))) 108 | 109 | Configuring sauna to use Uptime 110 | ------------------------------- 111 | 112 | In the last step of this tutorial you need to tell sauna where to find your plugin, this is done 113 | through the ``extra_plugins`` configuration parameter. It is a list of directories where sauna will 114 | look for modules: 115 | 116 | .. code-block:: yaml 117 | 118 | --- 119 | periodicity: 10 120 | extra_plugins: 121 | - /tmp/sauna_plugins 122 | 123 | consumers: 124 | 125 | Stdout: 126 | 127 | plugins: 128 | 129 | - type: Uptime 130 | checks: 131 | - type: uptime 132 | warn: 300 133 | crit: 60 134 | 135 | You can verify that sauna found your plugin by listing the available checks:: 136 | 137 | $ sauna list-available-checks 138 | 139 | Load: load1, load15, load5 140 | Uptime: uptime 141 | [...] 142 | 143 | Finally run sauna:: 144 | 145 | $ sauna 146 | 147 | ServiceCheck(name='uptime_uptime', status=0, output='Uptime is 4 days, 1:24:19.790000') 148 | -------------------------------------------------------------------------------- /sauna/consumers/ext/tcp_server.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import select 3 | from collections import defaultdict 4 | 5 | from sauna.consumers.base import AsyncConsumer 6 | from sauna.consumers import ConsumerRegister 7 | 8 | my_consumer = ConsumerRegister('TCPServer') 9 | 10 | 11 | @my_consumer.consumer() 12 | class TCPServerConsumer(AsyncConsumer): 13 | 14 | service_checks = {} 15 | 16 | def __init__(self, config): 17 | super().__init__(config) 18 | self.config = { 19 | 'port': config.get('port', 5555), 20 | 'backlog': config.get('port', 128), 21 | 'keepalive': config.get('keepalive', True) 22 | } 23 | self.read_wanted, self.write_wanted = ([], []) 24 | self.write_buffers = defaultdict(bytes) 25 | 26 | def _create_server(self): 27 | self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 28 | self.server.setblocking(0) 29 | self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 30 | self.server.bind(('', self.config['port'])) 31 | self.server.listen(self.config['backlog']) 32 | 33 | def _accept_new_connection(self): 34 | client_socket, address = self.server.accept() 35 | self.logger.debug('New connection from {}'.format(address[0])) 36 | self.write_wanted.append(client_socket) 37 | self.read_wanted.append(client_socket) 38 | return client_socket 39 | 40 | def _activate_keepalive(self, s, after_idle_sec=30, interval_sec=10, 41 | max_fails=5): 42 | """Set TCP keepalive on an open socket. 43 | 44 | It activates after 30 second (after_idle_sec) of idleness, 45 | then sends a keepalive ping once every 10 seconds (interval_sec), 46 | and closes the connection after 5 failed ping (max_fails). 47 | """ 48 | s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 49 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, after_idle_sec) 50 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, interval_sec) 51 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max_fails) 52 | 53 | def _close_socket(self, s): 54 | try: 55 | s.shutdown(socket.SHUT_RDWR) 56 | except socket.error: 57 | pass 58 | try: 59 | s.close() 60 | except socket.error: 61 | pass 62 | self._remove_from_list(self.write_wanted, s) 63 | self._remove_from_list(self.read_wanted, s) 64 | try: 65 | del self.write_buffers[s] 66 | except KeyError: 67 | pass 68 | self.logger.debug('Closed connection') 69 | 70 | @staticmethod 71 | def _remove_from_list(list_, value): 72 | while value in list_: 73 | try: 74 | list_.remove(value) 75 | except ValueError: 76 | pass 77 | 78 | def _handle_read_event(self, s): 79 | if s == self.server: 80 | client_socket = self._accept_new_connection() 81 | if self.config['keepalive']: 82 | self._activate_keepalive(client_socket) 83 | to_write = self.get_current_status()[0].encode() + b'\n' 84 | self.write_buffers[client_socket] += to_write 85 | self.write_wanted.append(client_socket) 86 | else: 87 | try: 88 | read_data = s.recv(4096) 89 | except socket.error as e: 90 | self.logger.debug( 91 | 'Error while receiving, closing connection: {}'.format(e) 92 | ) 93 | self._close_socket(s) 94 | return 95 | if len(read_data) == 0: 96 | self._close_socket(s) 97 | else: 98 | self.logger.debug('Received data') 99 | if b'\n' in read_data: 100 | to_write = self.get_current_status()[0].encode() + b'\n' 101 | self.write_buffers[s] += to_write 102 | self.write_wanted.append(s) 103 | 104 | def _handle_write_event(self, s): 105 | try: 106 | sent_len = s.send(self.write_buffers[s]) 107 | except socket.error as e: 108 | self.logger.debug( 109 | 'Error while sending, closing connection: {}'.format(e) 110 | ) 111 | self._close_socket(s) 112 | return 113 | self.write_buffers[s] = self.write_buffers[s][sent_len:] 114 | if not self.write_buffers[s]: 115 | self.write_wanted.remove(s) 116 | self.logger.debug('Sent data') 117 | 118 | def run(self, must_stop, *args): 119 | self._create_server() 120 | self.read_wanted = [self.server] 121 | self.write_wanted = [] 122 | 123 | while not must_stop.is_set(): 124 | readable, writable, errored = select.select( 125 | self.read_wanted, 126 | self.write_wanted, 127 | self.read_wanted + self.write_wanted, 128 | 1 129 | ) 130 | 131 | for s in errored: 132 | self.logger.debug('Connection in error, closing it') 133 | self._close_socket(s) 134 | 135 | for s in readable: 136 | self._handle_read_event(s) 137 | 138 | for s in writable: 139 | self._handle_write_event(s) 140 | 141 | self.logger.debug('Exited consumer thread') 142 | 143 | @staticmethod 144 | def config_sample(): 145 | return ''' 146 | # Listen on a TCP port and serve results to incoming connections 147 | - type: TCPServer 148 | port: 5555 149 | ''' 150 | -------------------------------------------------------------------------------- /sauna/consumers/ext/nsca.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import struct 3 | import binascii 4 | from copy import deepcopy 5 | import itertools 6 | 7 | from sauna.consumers.base import QueuedConsumer 8 | from sauna.consumers import ConsumerRegister 9 | 10 | my_consumer = ConsumerRegister('NSCA') 11 | 12 | 13 | def encrypt_xor(data, iv, key): 14 | for i in (iv, key): 15 | i = itertools.cycle(i) 16 | data = bytes(x ^ y for x, y in zip(data, i)) 17 | return data 18 | 19 | 20 | @my_consumer.consumer() 21 | class NSCAConsumer(QueuedConsumer): 22 | 23 | protocol_version = 3 24 | max_hostname_size = 64 25 | max_service_size = 128 26 | max_output_size = 4096 27 | 28 | init_payload_fmt = '!128sL' 29 | init_payload_size = struct.calcsize(init_payload_fmt) 30 | service_payload_fmt = '!hhIIh{}s{}s{}sh'.format( 31 | max_hostname_size, max_service_size, max_output_size 32 | ) 33 | service_payload_size = struct.calcsize(service_payload_fmt) 34 | 35 | encryption_functions = { 36 | 0: lambda x, y, z: x, 37 | 1: encrypt_xor 38 | } 39 | 40 | def __init__(self, config): 41 | super().__init__(config) 42 | self.config = { 43 | 'server': config.get('server', 'localhost'), 44 | 'port': config.get('port', 5667), 45 | 'timeout': config.get('timeout', 10), 46 | 'encryption': config.get('encryption', 0), 47 | 'key': config.get('key', '').encode('ascii'), 48 | } 49 | self._last_good_receiver_address = None 50 | 51 | def _recv_init_payload(self, s): 52 | init_payload = bytes() 53 | while len(init_payload) < self.init_payload_size: 54 | buffer = s.recv(self.init_payload_size - len(init_payload)) 55 | if buffer: 56 | init_payload += buffer 57 | return self._decode_init_payload(init_payload) 58 | 59 | def _decode_init_payload(self, init_payload): 60 | return struct.unpack(self.init_payload_fmt, init_payload) 61 | 62 | def _encode_service_payload(self, service_check): 63 | service_payload_list = [ 64 | self.protocol_version, 65 | 0, # Padding 66 | 0, # Placeholder for CRC 67 | service_check.timestamp, 68 | service_check.status, 69 | service_check.hostname.encode('utf8'), 70 | service_check.name.encode('utf8'), 71 | service_check.output.encode('utf8'), 72 | 0 # Padding 73 | ] 74 | crc = binascii.crc32(struct.pack(self.service_payload_fmt, 75 | *service_payload_list)) 76 | service_payload_list[2] = crc 77 | return struct.pack(self.service_payload_fmt, *service_payload_list) 78 | 79 | def _format_service_check(self, service_check): 80 | # C strings are null terminated, we need one extra char for each 81 | if len(service_check.hostname) > self.max_hostname_size - 1: 82 | raise ValueError('NSCA hostnames can be up to {} characters'. 83 | format(self.max_hostname_size - 1)) 84 | if len(service_check.name) > self.max_service_size - 1: 85 | raise ValueError('NSCA service names can be up to {} characters'. 86 | format(self.max_service_size - 1)) 87 | # Silently truncate output to its max length 88 | if len(service_check.output) > self.max_output_size - 1: 89 | service_check = deepcopy(service_check) 90 | truncate_output = self.max_output_size - 1 91 | service_check.output = service_check.output[:truncate_output] 92 | return service_check 93 | 94 | def _encrypt_service_payload(self, service_payload, iv): 95 | try: 96 | encryption_mode = self.config['encryption'] 97 | encryption_function = self.encryption_functions[encryption_mode] 98 | except KeyError: 99 | raise ValueError('Encryption mode not supported') 100 | data = encryption_function(service_payload, iv, self.config['key']) 101 | return data 102 | 103 | def _get_receivers_addresses(self): 104 | """Retrieve all the addresses associated with a hostname. 105 | 106 | It will return in priority the address of the last known receiver which 107 | accepted the previous check. 108 | """ 109 | receivers = socket.getaddrinfo( 110 | self.config['server'], self.config['port'], 111 | proto=socket.IPPROTO_TCP 112 | ) 113 | # Only keep the actual address 114 | addresses = [r[4][0] for r in receivers] 115 | try: 116 | addresses.remove(self._last_good_receiver_address) 117 | addresses = [self._last_good_receiver_address] + addresses 118 | except ValueError: 119 | pass 120 | return addresses 121 | 122 | def _send_to_receiver(self, service_check, receiver_address): 123 | with socket.socket() as s: 124 | s.settimeout(self.config['timeout']) 125 | s.connect((receiver_address, self.config['port'])) 126 | iv, timestamp = self._recv_init_payload(s) 127 | service_payload = self._encode_service_payload(service_check) 128 | s.sendall(self._encrypt_service_payload(service_payload, iv)) 129 | 130 | def _send(self, service_check): 131 | for receiver_address in self._get_receivers_addresses(): 132 | try: 133 | self._send_to_receiver(service_check, receiver_address) 134 | self._last_good_receiver_address = receiver_address 135 | return 136 | except OSError as e: 137 | self.logger.info('Could not send check to receiver {}: ' 138 | '{}'.format(receiver_address, e)) 139 | raise IOError('No receiver accepted the check') 140 | 141 | @staticmethod 142 | def config_sample(): 143 | return ''' 144 | # Send service check to a NSCA server 145 | # Only encryption methods 0 and 1 are supported 146 | # Max plugin output is 4096 bytes 147 | - type: NSCA 148 | server: receiver.shinken.tld 149 | port: 5667 150 | timeout: 10 151 | encryption: 1 152 | key: verylongkey 153 | ''' 154 | -------------------------------------------------------------------------------- /doc/user/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ============= 5 | 6 | Location 7 | -------- 8 | 9 | Sauna configuration is made of a single yaml file. By default it loads ``sauna.yml`` in the current 10 | directory. You can load another configuration file with the ``--config`` switch:: 11 | 12 | $ sauna --config /etc/sauna.yml 13 | 14 | .. note:: This configuration file might end up containing secrets to access your monitoring server. 15 | It is a good idea not to make it world readable. Only the user running sauna needs to 16 | be able to read it. 17 | 18 | Quickstart 19 | ---------- 20 | 21 | Sometimes simply editing a configuration file feels easier than reading documentation. You can 22 | generate a default configuration file:: 23 | 24 | $ sauna sample 25 | Created file ./sauna-sample.yml 26 | 27 | You can adapt this default configuration to fit your needs, when you are ready rename it and launch 28 | sauna:: 29 | 30 | $ mv sauna-sample.yml sauna.yml 31 | 32 | Content 33 | ------- 34 | 35 | The configuration yaml file contains three parts: 36 | 37 | * Generic parameters 38 | * Active consumers 39 | * Active plugins 40 | 41 | Generic parameters 42 | ~~~~~~~~~~~~~~~~~~ 43 | 44 | All these parameters can be left out, in this case they take their default value. 45 | 46 | **periodicity** 47 | How often, in seconds, will checks be run. The default value of 120 means that sauna will run 48 | all checks every two minutes. 49 | Individual checks that need to run more or less often can override their ``periodicity`` 50 | parameter. 51 | 52 | **hostname** 53 | The name of the host that will be reported to monitoring servers. The default value is the 54 | fully qualified domain name of your host. 55 | 56 | **extra_plugins** 57 | A list of directories where :ref:`additional plugins ` can be found. Defaults to no 58 | extra directory, meaning it does not load plugins beyond the core ones. 59 | 60 | **include** 61 | A path containing other configuration files to include. It can be used to separate each plugin 62 | in its own configuration file. File globs are expanded, example ``/etc/sauna.d/*.yml``. 63 | 64 | **concurrency** 65 | How many threads can process the checks at the same time. The default value of 1 means sauna 66 | will run checks one by one. Note that activating the concurrency system will, by default, only 67 | allow 1 check with the same name to run at the same time. 68 | 69 | **logging** 70 | Sauna writes logs to the standard output by default. The ``logging`` parameter allows to pass 71 | a custom logging configuration to change the log format, write logs to files, send them to 72 | syslog and much more. Check the :ref:`logging syntax` for the details. 73 | 74 | Example:: 75 | 76 | --- 77 | periodicity: 10 78 | extra_plugins: 79 | - /opt/sauna_plugins 80 | 81 | .. _configuration_consumers: 82 | 83 | Active consumers 84 | ~~~~~~~~~~~~~~~~ 85 | 86 | A list of the consumers you want to process your checks. It defines how sauna will interact with 87 | your monitoring server(s). 88 | 89 | Example:: 90 | 91 | --- 92 | consumers: 93 | 94 | - type: NSCA 95 | server: receiver.shinken.tld 96 | port: 5667 97 | timeout: 10 98 | 99 | Many consumers can be active at the same time and a consumer may be used more than once. 100 | 101 | .. _configuration_plugins: 102 | 103 | Active plugins 104 | ~~~~~~~~~~~~~~ 105 | 106 | A list of plugins and associated checks. 107 | 108 | Example:: 109 | 110 | --- 111 | plugins: 112 | 113 | # Usage of disks 114 | - type: Disk 115 | checks: 116 | - type: used_percent 117 | warn: 80% 118 | crit: 90% 119 | - type: used_inodes_percent 120 | warn: 80% 121 | crit: 90% 122 | periodicity: 300 123 | 124 | A plugin may be defined many times in the list. This allows to run the same checks with different 125 | configurations parameters. 126 | 127 | Plugin parameters 128 | ''''''''''''''''' 129 | 130 | Some plugins accept additional configuration options, for example:: 131 | 132 | - type: Redis 133 | checks: ... 134 | config: 135 | host: localhost 136 | port: 6379 137 | 138 | Unfortunately the parameters accepted by each plugins are not yet documented. 139 | 140 | Check parameters 141 | '''''''''''''''' 142 | 143 | **type** 144 | The kind of check as defined by the plugin. All types available are listed by the command 145 | ``sauna list-available-checks``. 146 | 147 | **warn** 148 | The warning threshold for the check. 149 | 150 | **crit** 151 | The critical threshold for the check. 152 | 153 | **name** 154 | Optional, overrides the default generated name of the check which is in the form 155 | ``plugin_type``. It becomes necessary to override the name when more than one checks of the 156 | same plugin and type are defined simultaneously. 157 | 158 | **periodicity** 159 | Optional, overrides the global periodicity for this check. Used to run a check at a different 160 | frequency than the others. 161 | 162 | .. _logging_syntax: 163 | 164 | Logging syntax 165 | ~~~~~~~~~~~~~~ 166 | 167 | By default Sauna writes logs with the level ``WARNING`` or the level passed by the 168 | ``--level`` flag in the command line to the standard output. 169 | 170 | To further customize how logs are processed, Sauna can also leverage `Python dictConfig 171 | `_. This allows 172 | the user to modify every aspect of the logging system, for instance: 173 | 174 | * Storing the logs in a file rotating every week 175 | * Silencing some log message but not others 176 | * Forwarding logs to syslog 177 | * Modifying the format of the logs 178 | 179 | To do that a dictionary configuration must be passed in the ``logging`` parameter of the 180 | configuration file. For example to remove the date from the record and write the message to 181 | stderr:: 182 | 183 | --- 184 | logging: 185 | version: 1 186 | formatters: 187 | simple: 188 | format: '%(message)s' 189 | handlers: 190 | console: 191 | class: logging.StreamHandler 192 | formatter: simple 193 | stream: ext://sys.stderr 194 | root: 195 | level: DEBUG 196 | handlers: [console] 197 | 198 | Make sure to read the `Python logging documentation 199 | `_ to go further. 200 | -------------------------------------------------------------------------------- /sauna/plugins/ext/processes.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from sauna.plugins import PluginRegister 4 | from sauna.plugins.base import PsutilPlugin 5 | 6 | my_plugin = PluginRegister('Processes') 7 | 8 | 9 | @my_plugin.plugin() 10 | class Processes(PsutilPlugin): 11 | 12 | @my_plugin.check() 13 | def count(self, check_config): 14 | num_pids = len(self.psutil.pids()) 15 | return ( 16 | self._value_to_status_less(num_pids, check_config), 17 | '{} processes'.format(num_pids) 18 | ) 19 | 20 | @my_plugin.check() 21 | def zombies(self, check_config): 22 | zombies = [p for p in self.psutil.process_iter() 23 | if p.status() == 'zombie'] 24 | num_zombies = len(zombies) 25 | return ( 26 | self._value_to_status_less(num_zombies, check_config), 27 | '{} zombies'.format(num_zombies) 28 | ) 29 | 30 | def _count_running_processes(self, check_config): 31 | """Count the number of times a process is running. 32 | 33 | Processes are identified by their first argument 'exec' and 34 | additional 'args'. 35 | :rtype int 36 | """ 37 | process_exec = check_config['exec'] 38 | required_args = check_config.get('args', '').split() 39 | instances = 0 40 | 41 | for process in self.psutil.process_iter(): 42 | try: 43 | cmdline = process.cmdline() 44 | except (self.psutil.NoSuchProcess, self.psutil.AccessDenied): 45 | # Zombies and processes that stopped throw NoSuchProcess 46 | continue 47 | 48 | # Often cmdline in an empty list 49 | if not cmdline: 50 | continue 51 | 52 | if cmdline[0] != process_exec: 53 | continue 54 | 55 | if self._required_args_are_in_cmdline(required_args, cmdline): 56 | instances += 1 57 | 58 | return instances 59 | 60 | @my_plugin.check() 61 | def running(self, check_config): 62 | process = check_config['exec'] 63 | nb = check_config.get('nb') 64 | instances = self._count_running_processes(check_config) 65 | 66 | if instances == 0: 67 | return self.STATUS_CRIT, 'Process {} not running'.format(process) 68 | if nb is None: 69 | return self.STATUS_OK, 'Process {} is running'.format(process) 70 | if instances == nb: 71 | return self.STATUS_OK, 'Process {} is running'.format(process) 72 | return (self.STATUS_WARN, 73 | 'Process {} is running {} times'.format(process, instances)) 74 | 75 | @my_plugin.check() 76 | def file_descriptors(self, check_config): 77 | """Check that processes and system are not running out of fd.""" 78 | check_config = self._strip_percent_sign_from_check_config(check_config) 79 | 80 | # Check fds of individuals processes 81 | names, worst_value = self._get_processes_exhausting_fds(check_config) 82 | if names: 83 | return ( 84 | self._value_to_status_less(worst_value, check_config), 85 | 'Processes running out of fd: {}'.format(', '.join(names)) 86 | ) 87 | 88 | # Check fds of whole system 89 | percent_used_fds = self._get_percent_system_used_fds() 90 | if (self._value_to_status_less(percent_used_fds, check_config) != 91 | self.STATUS_OK): 92 | return ( 93 | self._value_to_status_less(percent_used_fds, check_config), 94 | 'System using {}% of file descriptors'.format(percent_used_fds) 95 | ) 96 | 97 | return self.STATUS_OK, 'File descriptors under the limits' 98 | 99 | def _get_processes_exhausting_fds(self, check_config): 100 | processes_names = set() 101 | highest_percentage = 0 102 | for process in self.psutil.process_iter(): 103 | try: 104 | open_fd = process.num_fds() 105 | limit_fd = self._get_process_fd_limit(process.pid) 106 | percentage = int(open_fd * 100 / limit_fd) 107 | if (self._value_to_status_less(percentage, check_config) != 108 | self.STATUS_OK): 109 | processes_names.add(process.name()) 110 | if percentage > highest_percentage: 111 | highest_percentage = percentage 112 | except (self.psutil.NoSuchProcess, self.psutil.AccessDenied, 113 | OSError): 114 | pass 115 | return processes_names, highest_percentage 116 | 117 | @classmethod 118 | def _get_process_fd_limit(cls, pid): 119 | """Retrieve the soft limit of usable fds for a process.""" 120 | with open('/proc/{}/limits'.format(pid)) as f: 121 | limits = f.read() 122 | match = re.search(r'^Max open files\s+(\d+)\s+\d+\s+files', 123 | limits, flags=re.MULTILINE) 124 | if not match: 125 | raise Exception('Cannot parse /proc/{}/limits'.format(pid)) 126 | return int(match.group(1)) 127 | 128 | @classmethod 129 | def _get_percent_system_used_fds(cls): 130 | """Percentage of opened file descriptors over the whole system. 131 | 132 | :rtype int 133 | """ 134 | with open('/proc/sys/fs/file-nr') as f: 135 | # Weird behavior on Python 3.2 on Wheezy: 136 | # f.read() does not return the entire content of this 137 | # particular file while it should according to the docs. 138 | # As the file only contains a single line, f.readline() 139 | # does the job. 140 | file_nr = f.readline() 141 | match = re.match(r'^(\d+)\t\d+\t(\d+)$', file_nr) 142 | if not match: 143 | raise Exception('Cannot parse /proc/sys/fs/file-nr') 144 | system_opened_fds = int(match.group(1)) 145 | system_max_fds = int(match.group(2)) 146 | return int(system_opened_fds * 100 / system_max_fds) 147 | 148 | @classmethod 149 | def _required_args_are_in_cmdline(cls, required_args, cmdline): 150 | for arg in required_args: 151 | if arg not in cmdline[1:]: 152 | return False 153 | return True 154 | 155 | @staticmethod 156 | def config_sample(): 157 | return ''' 158 | # Information about processes 159 | - type: Processes 160 | checks: 161 | # Number of processes in the system 162 | - type: count 163 | warn: 400 164 | crit: 500 165 | # Number of zombies processes in the system 166 | - type: zombies 167 | warn: 1 168 | crit: 5 169 | # File descriptors 170 | - type: file_descriptors 171 | warn: 60% 172 | crit: 80% 173 | # Critical if process is not running 174 | - type: running 175 | name: docker_running 176 | exec: /usr/bin/docker 177 | args: daemon 178 | ''' 179 | -------------------------------------------------------------------------------- /sauna/consumers/base.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from datetime import timedelta, datetime 3 | from functools import reduce 4 | import logging 5 | from queue import Queue, Empty 6 | import threading 7 | import time 8 | 9 | 10 | class Consumer: 11 | 12 | def __init__(self, config: dict): 13 | if config is None: 14 | config = {} 15 | self.stale_age = config.get('stale_age', 300) 16 | self.retry_delay = config.get('retry_delay', 10) 17 | self.max_retry = config.get('max_retry', -1) 18 | 19 | @property 20 | def logger(self): 21 | return logging.getLogger('sauna.' + self.__class__.__name__) 22 | 23 | @classmethod 24 | def logging(cls, lvl, message): 25 | """Log a message. 26 | 27 | Deprecated, use self.logger instead. Kept for backward compatibility 28 | """ 29 | log = getattr(logging, lvl) 30 | message = '[{}] {}'.format(cls.__name__, message) 31 | log(message) 32 | 33 | def run(self, must_stop, queue): 34 | """Method to override in consumers.""" 35 | raise NotImplementedError() 36 | 37 | 38 | class BatchQueuedConsumer(Consumer): 39 | """Consumer that processes checks synchronously in batches. 40 | 41 | BatchQueuedConsumers wait for checks to appear on a queue. They buffer the 42 | checks until enough of them are available. They then send all checks in a 43 | single batch to the remote service. 44 | """ 45 | 46 | #: Maximum number of checks to send in a single batch. 47 | max_batch_size: int = 64 48 | 49 | #: Maximum amount of time to wait for the batch to be full before sending 50 | #: it. 51 | max_batch_delay: timedelta = timedelta(seconds=15) 52 | 53 | def _send(self, service_check): 54 | """Send one service checks. 55 | 56 | Method to override in consumers sending checks one by one. 57 | """ 58 | raise NotImplementedError() 59 | 60 | def _send_batch(self, service_checks: list): 61 | """Send a batch of service checks. 62 | 63 | Method to override in consumers for actually sending batches, otherwise 64 | checks are sent one by one using `self._send`. 65 | """ 66 | for service_check in service_checks: 67 | self._send(service_check) 68 | 69 | def try_send(self, service_checks: list, must_stop: threading.Event): 70 | try: 71 | last_service_check = service_checks[-1] 72 | except IndexError: 73 | return 74 | 75 | retry_count = 0 76 | while True: 77 | retry_count = retry_count + 1 78 | 79 | if last_service_check.timestamp + self.stale_age < time.time(): 80 | self.logger.warning('Dropping batch because it is too old') 81 | return 82 | 83 | if self.max_retry != -1 and retry_count > self.max_retry: 84 | self.logger.warning('Dropping batch because ' 85 | 'max_retry has been reached') 86 | return 87 | 88 | try: 89 | self._send_batch(service_checks) 90 | except Exception as e: 91 | self.logger.warning('Could not send batch (attempt {}/{}): {}' 92 | .format(retry_count, self.max_retry, e)) 93 | if must_stop.is_set(): 94 | return 95 | 96 | if self.max_retry == -1 or retry_count < self.max_retry: 97 | self._wait_before_retry(must_stop) 98 | else: 99 | self.logger.info('Batch sent') 100 | return 101 | 102 | def _wait_before_retry(self, must_stop: threading.Event): 103 | self.logger.info('Waiting %s s before retry', self.retry_delay) 104 | must_stop.wait(timeout=self.retry_delay) 105 | 106 | def run(self, must_stop, queue: Queue): 107 | batch = list() 108 | batch_created_at = datetime.utcnow() 109 | 110 | while not must_stop.is_set(): 111 | 112 | # Calculate how long to wait before the current batch 113 | # should be sent. 114 | if not batch: 115 | wait_timeout = None 116 | else: 117 | wait_timeout = ( 118 | batch_created_at + self.max_batch_delay - datetime.utcnow() 119 | ).total_seconds() 120 | if wait_timeout < 0: 121 | wait_timeout = 0 122 | 123 | try: 124 | service_check = queue.get(timeout=wait_timeout) 125 | except Empty: 126 | pass 127 | else: 128 | if not isinstance(service_check, threading.Event): 129 | self.logger.debug('Got check {}'.format(service_check)) 130 | if not batch: 131 | # Current batch is empty, create a new one 132 | batch.append(service_check) 133 | batch_created_at = datetime.utcnow() 134 | else: 135 | # Current batch is not empty, just append the check 136 | batch.append(service_check) 137 | 138 | # A batch should be sent if either: 139 | # - the batch isfull 140 | # - the first check has waited long enough in the batch 141 | # - sauna is shutting down 142 | should_send_batch = ( 143 | len(batch) >= self.max_batch_size 144 | or 145 | (batch_created_at + self.max_batch_delay) < datetime.utcnow() 146 | or 147 | must_stop.is_set() 148 | ) 149 | if should_send_batch: 150 | self.try_send(batch, must_stop) 151 | batch = list() 152 | 153 | self.logger.debug('Exited consumer thread') 154 | 155 | 156 | class QueuedConsumer(BatchQueuedConsumer): 157 | """Consumer that processes checks synchronously one by one. 158 | 159 | QueuedConsumers wait for checks to appear on a queue. They process each 160 | check one by one in order until the queue is empty. 161 | """ 162 | 163 | max_batch_size = 1 164 | 165 | 166 | class AsyncConsumer(Consumer): 167 | """Consumer that processes checks asynchronously. 168 | 169 | It is up to the consumer to read the checks when it needs to. No 170 | queueing is made. 171 | """ 172 | 173 | @classmethod 174 | def get_current_status(cls): 175 | """Get the worse status of all check results. 176 | 177 | :returns: (status as str, code) 178 | :rtype: tuple 179 | """ 180 | from sauna.plugins.base import Plugin 181 | from sauna import check_results_lock, check_results 182 | 183 | def reduce_status(accumulated, update_value): 184 | if update_value.status > Plugin.STATUS_CRIT: 185 | return accumulated 186 | return accumulated if accumulated > update_value.status else \ 187 | update_value.status 188 | 189 | with check_results_lock: 190 | code = reduce(reduce_status, check_results.values(), 0) 191 | 192 | return Plugin.status_code_to_str(code), code 193 | 194 | @classmethod 195 | def get_checks_as_dict(cls): 196 | from sauna.plugins.base import Plugin 197 | from sauna import check_results_lock, check_results 198 | 199 | checks = {} 200 | with check_results_lock: 201 | for service_check in check_results.values(): 202 | checks[service_check.name] = { 203 | 'status': Plugin.status_code_to_str(service_check.status), 204 | 'code': service_check.status, 205 | 'timestamp': service_check.timestamp, 206 | 'output': service_check.output 207 | } 208 | return deepcopy(checks) 209 | -------------------------------------------------------------------------------- /sauna/plugins/ext/supervisor.py: -------------------------------------------------------------------------------- 1 | import xmlrpc.client 2 | import http.client 3 | import socket 4 | 5 | from sauna.plugins import Plugin, PluginRegister 6 | 7 | my_plugin = PluginRegister('Supervisor') 8 | 9 | 10 | @my_plugin.plugin() 11 | class Supervisor(Plugin): 12 | 13 | def __init__(self, config): 14 | super().__init__(config) 15 | 16 | serverurl = config.get('serverurl', 'unix:///var/run/supervisor.sock') 17 | timeout = config.get('timeout', 5) 18 | if serverurl.startswith('unix://'): 19 | serverurl = serverurl.replace('unix://', '', 1) 20 | # xmlrpc.client does not support Unix sockets, so we must provide 21 | # a custom transport layer 22 | transport = UnixStreamTransport(serverurl, timeout=timeout) 23 | server = xmlrpc.client.ServerProxy('http://noop', 24 | transport=transport) 25 | else: 26 | transport = CustomHTTPTransport(timeout=timeout) 27 | server = xmlrpc.client.ServerProxy(serverurl, transport=transport) 28 | 29 | rpc_namespace = config.get('rpc_namespace', 'supervisor') 30 | self.supervisor = getattr(server, rpc_namespace) 31 | self.supervisor_addr = serverurl 32 | 33 | @my_plugin.check() 34 | def service(self, check_config): 35 | try: 36 | service = check_config['service'] 37 | except KeyError: 38 | raise KeyError('A name parameter is required for Supervisor ' 39 | 'service checks') 40 | states_threshold = self._get_states_threshold(check_config) 41 | 42 | try: 43 | service_info = self.supervisor.getProcessInfo(service) 44 | except Exception as ex: 45 | raise Exception('Error while contacting Supervisor at {}: {}' 46 | .format(self.supervisor_addr, ex)) 47 | 48 | service_state = service_info['statename'] 49 | status = self._get_status(service_state, states_threshold) 50 | 51 | return status, 'Service {} {}'.format(service, service_state) 52 | 53 | @my_plugin.check() 54 | def services(self, check_config): 55 | states_threshold = self._get_states_threshold(check_config) 56 | whitelist = check_config.get('whitelist', []) 57 | blacklist = check_config.get('blacklist', []) 58 | 59 | def service_enabled(name): 60 | if blacklist and name in blacklist: 61 | return False 62 | if whitelist and name not in whitelist: 63 | return False 64 | return True 65 | 66 | try: 67 | service_infos = self.supervisor.getAllProcessInfo() 68 | except Exception as ex: 69 | raise Exception('Error while contacting Supervisor at {}: {}' 70 | .format(self.supervisor_addr, ex)) 71 | 72 | service_states = {info['name']: info['statename'] 73 | for info in service_infos 74 | if service_enabled(info['name'])} 75 | service_statuses = {name: self._get_status(state, states_threshold) 76 | for name, state in service_states.items()} 77 | 78 | status_priority = [Plugin.STATUS_OK, Plugin.STATUS_UNKNOWN, 79 | Plugin.STATUS_WARN, Plugin.STATUS_CRIT] 80 | status_priority = {s: i for i, s in enumerate(status_priority)} 81 | 82 | global_status = Plugin.STATUS_OK 83 | for status in service_statuses.values(): 84 | if status_priority[status] > status_priority[global_status]: 85 | global_status = status 86 | 87 | faulty_services = [name for name, status in service_statuses.items() 88 | if status != Plugin.STATUS_OK] 89 | if faulty_services: 90 | msg = 'Found {} services out of {} with incorrect state: '\ 91 | .format(len(faulty_services), len(service_statuses)) +\ 92 | ', '.join('{} is {}'.format(name, service_states[name]) 93 | for name in faulty_services) 94 | else: 95 | msg = 'All {} services OK'.format(len(service_statuses)) 96 | 97 | return global_status, msg 98 | 99 | @staticmethod 100 | def _get_status(state, states_threshold): 101 | status_name = states_threshold.get(state, 'UNKNOWN') 102 | status = getattr(Plugin, 'STATUS_' + status_name, 103 | Plugin.STATUS_UNKNOWN) 104 | return status 105 | 106 | @staticmethod 107 | def _get_states_threshold(check_config): 108 | user_threshold = check_config.get('states', {}) 109 | states_threshold = { 110 | 'STARTING': 'OK', 111 | 'RUNNING': 'OK', 112 | 'BACKOFF': 'WARN', 113 | 'STOPPING': 'WARN', 114 | 'STOPPED': 'CRIT', 115 | 'FATAL': 'CRIT' 116 | # Other states are translated to unknown 117 | } 118 | states_threshold.update({a.upper(): b.upper() 119 | for a, b in user_threshold.items()}) 120 | return states_threshold 121 | 122 | @staticmethod 123 | def config_sample(): 124 | return ''' 125 | # Monitor services of a Supervisor instance 126 | # Each check can monitor one Supervisor process (type 'service') or all 127 | # of them (type 'services') 128 | # The serverurl option should be the same as the one from the 129 | # Supervisor config, with optional credentials added for HTTP(S) 130 | - type: Supervisor # Minimal config to check local services 131 | checks: 132 | - type: services 133 | - type: Supervisor 134 | config: 135 | serverurl: unix:///var/run/supervisor.sock # Same as default 136 | checks: 137 | - type: service 138 | name: supervisor_foo 139 | service: foo 140 | states: 141 | STOPPED: OK # Allowed: UNKOWN, OK, WARN and CRIT (default) 142 | - type: services 143 | name: supervisor_services 144 | blacklist: [foo] 145 | - type: Supervisor 146 | config: 147 | serverurl: http://bob:pa$$word@example.com:9001/RPC2 148 | rpc_namespace: ubervisor # Default is supervisor 149 | timeout: 10 # Default is 5s 150 | checks: 151 | - type: services 152 | name: ubervisor_services 153 | whitelist: [bar,spam] 154 | ''' 155 | 156 | 157 | # Thanks to Adaptation (http://stackoverflow.com/a/23837147) 158 | class UnixStreamHTTPConnection(http.client.HTTPConnection): 159 | def connect(self): 160 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 161 | sock.settimeout(self.timeout) 162 | sock.connect(self.host) 163 | self.sock = sock 164 | 165 | 166 | class UnixStreamTransport(xmlrpc.client.Transport): 167 | def __init__(self, socket_path, **conn_kwargs): 168 | self.socket_path = socket_path 169 | self._conn_kwargs = conn_kwargs 170 | super(UnixStreamTransport, self).__init__() 171 | 172 | def make_connection(self, host): 173 | return UnixStreamHTTPConnection(self.socket_path, **self._conn_kwargs) 174 | 175 | 176 | class CustomHTTPTransport(xmlrpc.client.Transport): 177 | def __init__(self, **conn_kwargs): 178 | self._conn_kwargs = conn_kwargs 179 | super(CustomHTTPTransport, self).__init__() 180 | 181 | def make_connection(self, host): 182 | # return an existing connection if possible. This allows 183 | # HTTP/1.1 keep-alive. 184 | if self._connection and host == self._connection[0]: 185 | return self._connection[1] 186 | # create a HTTP connection object from a host descriptor 187 | chost, self._extra_headers, x509 = self.get_host_info(host) 188 | self._connection = ( 189 | host, 190 | http.client.HTTPConnection(chost, **self._conn_kwargs) 191 | ) 192 | return self._connection[1] 193 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " epub3 to make an epub3" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | @echo " dummy to check syntax errors of document sources" 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf $(BUILDDIR)/* 55 | 56 | .PHONY: html 57 | html: 58 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 61 | 62 | .PHONY: dirhtml 63 | dirhtml: 64 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 65 | @echo 66 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 67 | 68 | .PHONY: singlehtml 69 | singlehtml: 70 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 71 | @echo 72 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 73 | 74 | .PHONY: pickle 75 | pickle: 76 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 77 | @echo 78 | @echo "Build finished; now you can process the pickle files." 79 | 80 | .PHONY: json 81 | json: 82 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 83 | @echo 84 | @echo "Build finished; now you can process the JSON files." 85 | 86 | .PHONY: htmlhelp 87 | htmlhelp: 88 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 89 | @echo 90 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 91 | ".hhp project file in $(BUILDDIR)/htmlhelp." 92 | 93 | .PHONY: qthelp 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Sauna.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Sauna.qhc" 102 | 103 | .PHONY: applehelp 104 | applehelp: 105 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 106 | @echo 107 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 108 | @echo "N.B. You won't be able to view it unless you put it in" \ 109 | "~/Library/Documentation/Help or install it in your application" \ 110 | "bundle." 111 | 112 | .PHONY: devhelp 113 | devhelp: 114 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 115 | @echo 116 | @echo "Build finished." 117 | @echo "To view the help file:" 118 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Sauna" 119 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Sauna" 120 | @echo "# devhelp" 121 | 122 | .PHONY: epub 123 | epub: 124 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 125 | @echo 126 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 127 | 128 | .PHONY: epub3 129 | epub3: 130 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 131 | @echo 132 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 133 | 134 | .PHONY: latex 135 | latex: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo 138 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 139 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 140 | "(use \`make latexpdf' here to do that automatically)." 141 | 142 | .PHONY: latexpdf 143 | latexpdf: 144 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 145 | @echo "Running LaTeX files through pdflatex..." 146 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 147 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 148 | 149 | .PHONY: latexpdfja 150 | latexpdfja: 151 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 152 | @echo "Running LaTeX files through platex and dvipdfmx..." 153 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 154 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 155 | 156 | .PHONY: text 157 | text: 158 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 159 | @echo 160 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 161 | 162 | .PHONY: man 163 | man: 164 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 165 | @echo 166 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 167 | 168 | .PHONY: texinfo 169 | texinfo: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo 172 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 173 | @echo "Run \`make' in that directory to run these through makeinfo" \ 174 | "(use \`make info' here to do that automatically)." 175 | 176 | .PHONY: info 177 | info: 178 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 179 | @echo "Running Texinfo files through makeinfo..." 180 | make -C $(BUILDDIR)/texinfo info 181 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 182 | 183 | .PHONY: gettext 184 | gettext: 185 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 186 | @echo 187 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 188 | 189 | .PHONY: changes 190 | changes: 191 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 192 | @echo 193 | @echo "The overview file is in $(BUILDDIR)/changes." 194 | 195 | .PHONY: linkcheck 196 | linkcheck: 197 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 198 | @echo 199 | @echo "Link check complete; look for any errors in the above output " \ 200 | "or in $(BUILDDIR)/linkcheck/output.txt." 201 | 202 | .PHONY: doctest 203 | doctest: 204 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 205 | @echo "Testing of doctests in the sources finished, look at the " \ 206 | "results in $(BUILDDIR)/doctest/output.txt." 207 | 208 | .PHONY: coverage 209 | coverage: 210 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 211 | @echo "Testing of coverage in the sources finished, look at the " \ 212 | "results in $(BUILDDIR)/coverage/python.txt." 213 | 214 | .PHONY: xml 215 | xml: 216 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 217 | @echo 218 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 219 | 220 | .PHONY: pseudoxml 221 | pseudoxml: 222 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 223 | @echo 224 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 225 | 226 | .PHONY: dummy 227 | dummy: 228 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 229 | @echo 230 | @echo "Build finished. Dummy builder generates no files." 231 | -------------------------------------------------------------------------------- /tests/test_consumers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import threading 3 | import socket 4 | import os 5 | try: 6 | from unittest import mock 7 | except ImportError: 8 | # Python 3.2 does not have mock in the standard library 9 | import mock 10 | 11 | from sauna import Sauna, ServiceCheck 12 | from sauna.consumers import base, ConsumerRegister 13 | from sauna.consumers.ext import nsca 14 | from sauna.consumers.ext.http_server.html import get_check_html 15 | 16 | 17 | class DumbConsumer(base.QueuedConsumer): 18 | 19 | times_called = 0 20 | fail_next = False 21 | last_service_check = None 22 | 23 | def _send(self, service_check): 24 | self.times_called += 1 25 | if self.fail_next: 26 | self.fail_next = False 27 | raise RuntimeError('Send check failed') 28 | self.last_service_check = service_check 29 | 30 | def _wait_before_retry(self, must_stop): 31 | pass 32 | 33 | 34 | class ConsumersTest(unittest.TestCase): 35 | 36 | def test_get_all_consumers(self): 37 | Sauna.import_submodules('sauna.consumers.ext') 38 | consumers = ConsumerRegister.all_consumers 39 | self.assertIsInstance(consumers, dict) 40 | self.assertGreater(len(consumers), 1) 41 | for consumer_name, consumer_info in consumers.items(): 42 | self.assertIn('consumer_cls', consumer_info) 43 | self.assertTrue(issubclass(consumer_info['consumer_cls'], 44 | base.Consumer)) 45 | 46 | def test_get_consumer(self): 47 | stdout_consumer = ConsumerRegister.get_consumer('Stdout') 48 | self.assertTrue(issubclass(stdout_consumer['consumer_cls'], 49 | base.Consumer)) 50 | must_be_none = ConsumerRegister.get_consumer('Unknown') 51 | self.assertIsNone(must_be_none) 52 | 53 | @mock.patch('sauna.consumers.base.time') 54 | def test_consumer_send_success(self, time_mock): 55 | time_mock.time.return_value = 1461363313 56 | must_stop = threading.Event() 57 | s = ServiceCheck( 58 | timestamp=1461363313, 59 | hostname='node-1.domain.tld', 60 | name='dumb_check', 61 | status=0, 62 | output='Check okay' 63 | ) 64 | dumb_consumer = DumbConsumer({}) 65 | dumb_consumer.try_send([s], must_stop) 66 | self.assertIs(s, dumb_consumer.last_service_check) 67 | 68 | @mock.patch('sauna.consumers.base.time') 69 | def test_consumer_send_failure(self, time_mock): 70 | time_mock.time.return_value = 1461363313 71 | must_stop = threading.Event() 72 | s = ServiceCheck( 73 | timestamp=1461363313, 74 | hostname='node-1.domain.tld', 75 | name='dumb_check', 76 | status=0, 77 | output='Check okay' 78 | ) 79 | dumb_consumer = DumbConsumer({}) 80 | dumb_consumer.fail_next = True 81 | dumb_consumer.try_send([s], must_stop) 82 | self.assertIs(s, dumb_consumer.last_service_check) 83 | self.assertEqual(2, dumb_consumer.times_called) 84 | 85 | def test_wait_before_retry(self): 86 | must_stop = mock.Mock() 87 | stdout_consumer = ( 88 | ConsumerRegister.get_consumer('Stdout')['consumer_cls']({}) 89 | ) 90 | stdout_consumer._wait_before_retry(must_stop) 91 | must_stop.wait.assert_called_once_with( 92 | timeout=stdout_consumer.retry_delay 93 | ) 94 | 95 | def test_get_current_status(self): 96 | foo = ServiceCheck(timestamp=42, hostname='server1', 97 | name='foo', status=0, output='foo out') 98 | bar = ServiceCheck(timestamp=42, hostname='server1', 99 | name='bar', status=1, output='bar out') 100 | with mock.patch.dict('sauna.check_results'): 101 | self.assertEqual(base.AsyncConsumer.get_current_status(), 102 | ('OK', 0)) 103 | with mock.patch.dict('sauna.check_results', foo=foo): 104 | self.assertEqual(base.AsyncConsumer.get_current_status(), 105 | ('OK', 0)) 106 | with mock.patch.dict('sauna.check_results', foo=foo, bar=bar): 107 | self.assertEqual(base.AsyncConsumer.get_current_status(), 108 | ('WARNING', 1)) 109 | 110 | def test_get_checks_as_dict(self): 111 | foo = ServiceCheck(timestamp=42, hostname='server1', 112 | name='foo', status=0, output='foo out') 113 | bar = ServiceCheck(timestamp=42, hostname='server1', 114 | name='bar', status=1, output='bar out') 115 | with mock.patch.dict('sauna.check_results', foo=foo, bar=bar): 116 | self.assertDictEqual(base.AsyncConsumer.get_checks_as_dict(), { 117 | 'foo': { 118 | 'status': 'OK', 119 | 'code': 0, 120 | 'timestamp': 42, 121 | 'output': 'foo out' 122 | }, 123 | 'bar': { 124 | 'status': 'WARNING', 125 | 'code': 1, 126 | 'timestamp': 42, 127 | 'output': 'bar out' 128 | } 129 | }) 130 | 131 | 132 | class ConsumerNSCATest(unittest.TestCase): 133 | 134 | def setUp(self): 135 | self.nsca = nsca.NSCAConsumer({}) 136 | 137 | def test_encrypt_xor(self): 138 | encrypt_xor = nsca.encrypt_xor 139 | data = bytes.fromhex('0000') 140 | iv = bytes.fromhex('0000') 141 | key = bytes.fromhex('0000') 142 | self.assertEqual(encrypt_xor(data, iv, key), bytes.fromhex('0000')) 143 | 144 | data = bytes.fromhex('0000') 145 | iv = bytes.fromhex('FF') 146 | key = bytes.fromhex('00') 147 | self.assertEqual(encrypt_xor(data, iv, key), bytes.fromhex('FFFF')) 148 | 149 | data = bytes.fromhex('7DE8') 150 | iv = bytes.fromhex('8ECA') 151 | key = bytes.fromhex('E1D0') 152 | self.assertEqual(encrypt_xor(data, iv, key), bytes.fromhex('12F2')) 153 | 154 | def test_no_encryption(self): 155 | self.nsca.config['encryption'] = 0 156 | self.assertEqual( 157 | self.nsca._encrypt_service_payload(bytes.fromhex('EEEE'), 158 | bytes.fromhex('5555')), 159 | bytes.fromhex('EEEE') 160 | ) 161 | 162 | def test_xor_encryption(self): 163 | self.nsca.config.update({'encryption': 1, 'key': b'plop'}) 164 | self.assertEqual( 165 | self.nsca._encrypt_service_payload(bytes.fromhex('EEEE'), 166 | bytes.fromhex('5555')), 167 | bytes.fromhex('CBD7') 168 | ) 169 | 170 | @mock.patch('sauna.consumers.ext.nsca.socket') 171 | def test_get_receivers_addresses(self, socket_mock): 172 | socket_mock.getaddrinfo.return_value = [ 173 | (None, None, None, None, ('7.7.7.7', 5667)), 174 | (None, None, None, None, ('8.8.8.8', 5667)), 175 | (None, None, None, None, ('9.9.9.9', 5667)) 176 | ] 177 | self.assertListEqual(self.nsca._get_receivers_addresses(), 178 | ['7.7.7.7', '8.8.8.8', '9.9.9.9']) 179 | 180 | # Test with an already known good receiver 181 | self.nsca._last_good_receiver_address = '9.9.9.9' 182 | self.assertListEqual(self.nsca._get_receivers_addresses(), 183 | ['9.9.9.9', '7.7.7.7', '8.8.8.8']) 184 | 185 | def test_send(self): 186 | self.nsca._get_receivers_addresses = lambda: ['7.7.7.7', '8.8.8.8'] 187 | self.nsca._send_to_receiver = lambda x, y: None 188 | 189 | self.assertEqual(self.nsca._last_good_receiver_address, None) 190 | self.nsca._send(None) 191 | self.assertEqual(self.nsca._last_good_receiver_address, '7.7.7.7') 192 | 193 | def raise_socket_timeout(*args, **kwargs): 194 | raise socket.timeout() 195 | 196 | self.nsca._send_to_receiver = raise_socket_timeout 197 | with self.assertRaises(IOError): 198 | self.nsca._send(None) 199 | self.assertEqual(self.nsca._last_good_receiver_address, '7.7.7.7') 200 | 201 | 202 | class ConsumerHTTPTest(unittest.TestCase): 203 | @mock.patch('sauna.consumers.base.AsyncConsumer.get_checks_as_dict') 204 | def test_escape_html(self, m_get_checks_as_dict): 205 | os.environ['TZ'] = 'UTC' 206 | m_get_checks_as_dict.return_value = { 207 | '

test

': { 208 | 'status': 'Warning', 209 | 'code': 1, 210 | 'timestamp': 12345678, 211 | 'output': "" 212 | } 213 | } 214 | html_check = get_check_html() 215 | self.assertEqual( 216 | html_check, 217 | '<h1>test</h1>' 218 | 'Warning' 219 | '<script>test</script>' 220 | '1970-05-23 21:21:18') 221 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import unittest 3 | try: 4 | from unittest import mock 5 | except ImportError: 6 | # Python 3.2 does not have mock in the standard library 7 | import mock 8 | 9 | import yaml 10 | 11 | from sauna import Sauna, _merge_config 12 | 13 | 14 | class ConfigTest(unittest.TestCase): 15 | 16 | def test_dict_conf(self): 17 | dict_conf = { 18 | "plugins": { 19 | "Disk": { 20 | "config": { 21 | "myconf": "myvalue" 22 | }, 23 | "checks": [ 24 | { 25 | "type": "used_percent", 26 | "warn": "80%", 27 | "crit": "90%" 28 | } 29 | ] 30 | } 31 | } 32 | } 33 | expected_result = [ 34 | { 35 | 'type': 'Disk', 36 | "config": { 37 | "myconf": "myvalue" 38 | }, 39 | "checks": [ 40 | { 41 | "type": "used_percent", 42 | "warn": "80%", 43 | "crit": "90%" 44 | } 45 | ] 46 | } 47 | ] 48 | sauna = Sauna(config=dict_conf) 49 | self.assertEqual(sauna.plugins_checks, expected_result) 50 | 51 | def test_list_conf(self): 52 | list_conf = { 53 | "plugins": [ 54 | { 55 | 'type': 'Disk', 56 | "config": { 57 | "myconf": "myvalue" 58 | }, 59 | "checks": [ 60 | { 61 | "type": "used_percent", 62 | "warn": "80%", 63 | "crit": "90%" 64 | } 65 | ] 66 | } 67 | ] 68 | } 69 | 70 | sauna = Sauna(config=list_conf) 71 | self.assertEqual(sauna.plugins_checks, list_conf['plugins']) 72 | 73 | def test_complex_dict_conf(self): 74 | dict_conf = { 75 | "plugins": { 76 | "Disk": { 77 | "config": { 78 | "myconf": "myvalue" 79 | }, 80 | "checks": [ 81 | { 82 | "type": "used_percent", 83 | "warn": "80%", 84 | "crit": "90%" 85 | }, 86 | { 87 | "type": "used_percent", 88 | "warn": "80%", 89 | "crit": "90%" 90 | } 91 | ] 92 | }, 93 | "Memory": { 94 | "config": { 95 | "myconf": "myvalue" 96 | }, 97 | "checks": [ 98 | { 99 | "type": "used_percent", 100 | "warn": "80%", 101 | "crit": "90%" 102 | }, 103 | ] 104 | } 105 | } 106 | } 107 | expected_result = [ 108 | { 109 | 'type': 'Disk', 110 | "config": { 111 | "myconf": "myvalue" 112 | }, 113 | "checks": [ 114 | { 115 | "type": "used_percent", 116 | "warn": "80%", 117 | "crit": "90%" 118 | }, 119 | { 120 | "type": "used_percent", 121 | "warn": "80%", 122 | "crit": "90%" 123 | } 124 | ] 125 | }, 126 | { 127 | "type": "Memory", 128 | "config": { 129 | "myconf": "myvalue" 130 | }, 131 | "checks": [ 132 | { 133 | "type": "used_percent", 134 | "warn": "80%", 135 | "crit": "90%" 136 | }, 137 | ] 138 | } 139 | ] 140 | sauna = Sauna(config=dict_conf) 141 | self.assertEqual(len(sauna.plugins_checks), len(expected_result)) 142 | for elem in sauna.plugins_checks: 143 | self.assertIn(elem, expected_result, 'missing element') 144 | 145 | def test_consumers_dict_conf(self): 146 | dict_conf = { 147 | 'consumers': { 148 | 'NSCA': { 149 | 'foo': 'bar' 150 | }, 151 | 'Stdout': None 152 | } 153 | } 154 | expected_result = [ 155 | { 156 | 'type': 'NSCA', 157 | 'foo': 'bar' 158 | }, 159 | { 160 | 'type': 'Stdout', 161 | } 162 | ] 163 | sauna = Sauna(config=dict_conf) 164 | for r in expected_result: 165 | self.assertTrue(r in sauna.consumers) 166 | 167 | def test_consumers_list_conf(self): 168 | list_conf = { 169 | 'consumers': [ 170 | { 171 | 'type': 'NSCA', 172 | 'foo': 'bar' 173 | }, 174 | { 175 | 'type': 'Stdout', 176 | } 177 | ] 178 | } 179 | sauna = Sauna(config=list_conf) 180 | for r in list_conf['consumers']: 181 | self.assertTrue(r in sauna.consumers) 182 | 183 | def test_merge_config(self): 184 | original = { 185 | 'periodicity': 60, 186 | 'consumers': { 187 | 'Stdout': {} 188 | }, 189 | 'plugins': [ 190 | { 191 | 'type': 'Disk', 192 | "config": { 193 | "myconf": "myvalue" 194 | }, 195 | "checks": [ 196 | { 197 | "type": "used_percent", 198 | "warn": "80%", 199 | "crit": "90%" 200 | }, 201 | { 202 | "type": "used_percent", 203 | "warn": "80%", 204 | "crit": "90%" 205 | } 206 | ] 207 | } 208 | ] 209 | } 210 | 211 | # Not changing anthing 212 | expected = copy.deepcopy(original) 213 | _merge_config(original, {}) 214 | self.assertDictEqual(original, expected) 215 | 216 | # Adding a consumer 217 | expected['consumers']['NSCA'] = {} 218 | _merge_config(original, {'consumers': {'NSCA': {}}}) 219 | self.assertDictEqual(original, expected) 220 | 221 | # Adding a plugin 222 | expected['plugins'].append({'type': 'Load'}) 223 | _merge_config(original, {'plugins': [{'type': 'Load'}]}) 224 | self.assertDictEqual(original, expected) 225 | 226 | # Adding a root property 227 | expected['hostname'] = 'host-1.domain.tld' 228 | _merge_config(original, {'hostname': 'host-1.domain.tld'}) 229 | self.assertDictEqual(original, expected) 230 | 231 | # Appending to a non existent list 232 | expected['extra_plugins'] = ['/opt/plugins1', '/opt/plugins2'] 233 | _merge_config(original, 234 | {'extra_plugins': ['/opt/plugins1', '/opt/plugins2']}) 235 | self.assertDictEqual(original, expected) 236 | 237 | def test_assemble_config_sample(self): 238 | mock_open = mock.mock_open() 239 | sauna_instance = Sauna() 240 | with mock.patch('builtins.open', mock_open): 241 | sauna_instance.assemble_config_sample('/foo') 242 | mock_open.assert_called_once_with('/foo/sauna-sample.yml', 'w') 243 | f = mock_open() 244 | generated_yaml_string = f.write.call_args[0][0] 245 | # Will raise a yaml error if generated content is not valid yaml 246 | yaml.safe_load(generated_yaml_string) 247 | 248 | def test_conf_with_concurrency_instantiates_threadpool(self): 249 | original = { 250 | 'periodicity': 60, 251 | 'concurrency': 5, 252 | 'consumers': { 253 | 'Stdout': {} 254 | }, 255 | 'plugins': [] 256 | } 257 | sauna = Sauna(config=original) 258 | self.assertIsNotNone(sauna._thread_pool) 259 | 260 | def test_conf_without_concurrency_no_threadpool(self): 261 | original = { 262 | 'periodicity': 60, 263 | 'consumers': { 264 | 'Stdout': {}, 265 | }, 266 | 'plugins': [] 267 | } 268 | sauna = Sauna(config=original) 269 | self.assertIsNone(sauna._thread_pool) 270 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Sauna documentation build configuration file, created by 5 | # sphinx-quickstart on Fri May 6 22:17:12 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | import sauna 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.githubpages', 37 | 'sphinx.ext.intersphinx', 38 | 'sphinx.ext.todo' 39 | ] 40 | 41 | intersphinx_mapping = { 42 | 'python': ('https://docs.python.org/3', None) 43 | } 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # source_suffix = ['.rst', '.md'] 51 | source_suffix = '.rst' 52 | 53 | # The encoding of source files. 54 | #source_encoding = 'utf-8-sig' 55 | 56 | # The master toctree document. 57 | master_doc = 'index' 58 | 59 | # General information about the project. 60 | project = 'Sauna' 61 | copyright = '2016, Nicolas Le Manchet' 62 | author = 'Nicolas Le Manchet' 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | version = sauna.__version__ 70 | # The full version, including alpha/beta/rc tags. 71 | release = sauna.__version__ 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = None 79 | 80 | # There are two options for replacing |today|: either, you set today to some 81 | # non-false value, then it is used: 82 | #today = '' 83 | # Else, today_fmt is used as the format for a strftime call. 84 | #today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | # This patterns also effect to html_static_path and html_extra_path 89 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 90 | 91 | # The reST default role (used for this markup: `text`) to use for all 92 | # documents. 93 | #default_role = None 94 | 95 | # If true, '()' will be appended to :func: etc. cross-reference text. 96 | #add_function_parentheses = True 97 | 98 | # If true, the current module name will be prepended to all description 99 | # unit titles (such as .. function::). 100 | #add_module_names = True 101 | 102 | # If true, sectionauthor and moduleauthor directives will be shown in the 103 | # output. They are ignored by default. 104 | #show_authors = False 105 | 106 | # The name of the Pygments (syntax highlighting) style to use. 107 | pygments_style = 'sphinx' 108 | 109 | # A list of ignored prefixes for module index sorting. 110 | #modindex_common_prefix = [] 111 | 112 | # If true, keep warnings as "system message" paragraphs in the built documents. 113 | #keep_warnings = False 114 | 115 | # If true, `todo` and `todoList` produce output, else they produce nothing. 116 | todo_include_todos = True 117 | 118 | 119 | # -- Options for HTML output ---------------------------------------------- 120 | 121 | # The theme to use for HTML and HTML Help pages. See the documentation for 122 | # a list of builtin themes. 123 | html_theme = 'alabaster' 124 | 125 | # Theme options are theme-specific and customize the look and feel of a theme 126 | # further. For a list of options available for each theme, see the 127 | # documentation. 128 | html_theme_options = { 129 | 'description': 'Lightweight daemon that runs and reports health checks', 130 | 'github_user': 'NicolasLM', 131 | 'github_repo': 'sauna', 132 | 'github_button': False 133 | } 134 | 135 | # Add any paths that contain custom themes here, relative to this directory. 136 | #html_theme_path = [] 137 | 138 | # The name for this set of Sphinx documents. 139 | # " v documentation" by default. 140 | #html_title = 'Sauna v0.0.6' 141 | 142 | # A shorter title for the navigation bar. Default is the same as html_title. 143 | #html_short_title = None 144 | 145 | # The name of an image file (relative to this directory) to place at the top 146 | # of the sidebar. 147 | #html_logo = None 148 | 149 | # The name of an image file (relative to this directory) to use as a favicon of 150 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 151 | # pixels large. 152 | #html_favicon = None 153 | 154 | # Add any paths that contain custom static files (such as style sheets) here, 155 | # relative to this directory. They are copied after the builtin static files, 156 | # so a file named "default.css" will overwrite the builtin "default.css". 157 | html_static_path = ['_static'] 158 | 159 | # Add any extra paths that contain custom files (such as robots.txt or 160 | # .htaccess) here, relative to this directory. These files are copied 161 | # directly to the root of the documentation. 162 | #html_extra_path = [] 163 | 164 | # If not None, a 'Last updated on:' timestamp is inserted at every page 165 | # bottom, using the given strftime format. 166 | # The empty string is equivalent to '%b %d, %Y'. 167 | #html_last_updated_fmt = None 168 | 169 | # If true, SmartyPants will be used to convert quotes and dashes to 170 | # typographically correct entities. 171 | #html_use_smartypants = True 172 | 173 | # Custom sidebar templates, maps document names to template names. 174 | html_sidebars = { 175 | '**': ['about.html', 'navigation.html', 'searchbox.html'] 176 | } 177 | 178 | # Additional templates that should be rendered to pages, maps page names to 179 | # template names. 180 | #html_additional_pages = {} 181 | 182 | # If false, no module index is generated. 183 | #html_domain_indices = True 184 | 185 | # If false, no index is generated. 186 | #html_use_index = True 187 | 188 | # If true, the index is split into individual pages for each letter. 189 | #html_split_index = False 190 | 191 | # If true, links to the reST sources are added to the pages. 192 | html_show_sourcelink = False 193 | 194 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 195 | #html_show_sphinx = True 196 | 197 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 198 | #html_show_copyright = True 199 | 200 | # If true, an OpenSearch description file will be output, and all pages will 201 | # contain a tag referring to it. The value of this option must be the 202 | # base URL from which the finished HTML is served. 203 | #html_use_opensearch = '' 204 | 205 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 206 | #html_file_suffix = None 207 | 208 | # Language to be used for generating the HTML full-text search index. 209 | # Sphinx supports the following languages: 210 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 211 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 212 | #html_search_language = 'en' 213 | 214 | # A dictionary with options for the search language support, empty by default. 215 | # 'ja' uses this config value. 216 | # 'zh' user can custom change `jieba` dictionary path. 217 | #html_search_options = {'type': 'default'} 218 | 219 | # The name of a javascript file (relative to the configuration directory) that 220 | # implements a search results scorer. If empty, the default will be used. 221 | #html_search_scorer = 'scorer.js' 222 | 223 | # Output file base name for HTML help builder. 224 | htmlhelp_basename = 'Saunadoc' 225 | 226 | # -- Options for LaTeX output --------------------------------------------- 227 | 228 | latex_elements = { 229 | # The paper size ('letterpaper' or 'a4paper'). 230 | #'papersize': 'letterpaper', 231 | 232 | # The font size ('10pt', '11pt' or '12pt'). 233 | #'pointsize': '10pt', 234 | 235 | # Additional stuff for the LaTeX preamble. 236 | #'preamble': '', 237 | 238 | # Latex figure (float) alignment 239 | #'figure_align': 'htbp', 240 | } 241 | 242 | # Grouping the document tree into LaTeX files. List of tuples 243 | # (source start file, target name, title, 244 | # author, documentclass [howto, manual, or own class]). 245 | latex_documents = [ 246 | (master_doc, 'Sauna.tex', 'Sauna Documentation', 247 | 'Nicolas Le Manchet', 'manual'), 248 | ] 249 | 250 | # The name of an image file (relative to this directory) to place at the top of 251 | # the title page. 252 | #latex_logo = None 253 | 254 | # For "manual" documents, if this is true, then toplevel headings are parts, 255 | # not chapters. 256 | #latex_use_parts = False 257 | 258 | # If true, show page references after internal links. 259 | #latex_show_pagerefs = False 260 | 261 | # If true, show URL addresses after external links. 262 | #latex_show_urls = False 263 | 264 | # Documents to append as an appendix to all manuals. 265 | #latex_appendices = [] 266 | 267 | # If false, no module index is generated. 268 | #latex_domain_indices = True 269 | 270 | 271 | # -- Options for manual page output --------------------------------------- 272 | 273 | # One entry per manual page. List of tuples 274 | # (source start file, name, description, authors, manual section). 275 | man_pages = [ 276 | (master_doc, 'sauna', 'Sauna Documentation', 277 | [author], 1) 278 | ] 279 | 280 | # If true, show URL addresses after external links. 281 | #man_show_urls = False 282 | 283 | 284 | # -- Options for Texinfo output ------------------------------------------- 285 | 286 | # Grouping the document tree into Texinfo files. List of tuples 287 | # (source start file, target name, title, author, 288 | # dir menu entry, description, category) 289 | texinfo_documents = [ 290 | (master_doc, 'Sauna', 'Sauna Documentation', 291 | author, 'Sauna', 'One line description of project.', 292 | 'Miscellaneous'), 293 | ] 294 | 295 | # Documents to append as an appendix to all manuals. 296 | #texinfo_appendices = [] 297 | 298 | # If false, no module index is generated. 299 | #texinfo_domain_indices = True 300 | 301 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 302 | #texinfo_show_urls = 'footnote' 303 | 304 | # If true, do not generate a @detailmenu in the "Top" node's menu. 305 | #texinfo_no_detailmenu = False 306 | --------------------------------------------------------------------------------