├── sensu_plugin ├── tests │ ├── __init__.py │ ├── test_pushevent.py │ ├── example_configs.py │ ├── test_plugin.py │ ├── test_check.py │ ├── example_configs_utils.py │ ├── test_utils.py │ ├── test_metric.py │ └── test_handler.py ├── compat.py ├── __init__.py ├── exithook.py ├── pushevent.py ├── check.py ├── plugin.py ├── metric.py ├── utils.py └── handler.py ├── .coveragerc ├── MANIFEST.in ├── requirements_test.txt ├── .github ├── dependabot.yml └── PULL_REQUEST_TEMPLATE.md ├── docker ├── docker_build │ ├── update │ ├── 3.4 │ │ ├── Dockerfile │ │ └── setup.sh │ ├── 3.5 │ │ ├── Dockerfile │ │ └── setup.sh │ ├── 3.6 │ │ ├── Dockerfile │ │ └── setup.sh │ ├── 3.7 │ │ ├── Dockerfile │ │ └── setup.sh │ ├── 2.7 │ │ ├── Dockerfile │ │ └── setup.sh │ └── setup.sh ├── README.md └── docker-compose.yml ├── .gitignore ├── setup.py ├── LICENSE ├── .travis.yml ├── run_tests ├── README.md ├── CHANGELOG.md └── pylint.rc /sensu_plugin/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | show_missing = True 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.md 2 | recursive-include docs *.txt *.md 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | argparse 2 | nose 3 | pycodestyle 4 | pylint 5 | coverage 6 | requests 7 | pytest 8 | mock 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /docker/docker_build/update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | versions="2.7 3.4 3.5 3.6 3.7" 4 | 5 | echo $versions | xargs -n 1 cp ./setup.sh 2> /dev/null 6 | -------------------------------------------------------------------------------- /docker/docker_build/3.4/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.4 2 | 3 | COPY setup.sh /setup.sh 4 | 5 | RUN chmod +x /setup.sh && \ 6 | /setup.sh 7 | 8 | ENTRYPOINT [ "/entrypoint.sh", "docker" ] 9 | -------------------------------------------------------------------------------- /docker/docker_build/3.5/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | COPY setup.sh /setup.sh 4 | 5 | RUN chmod +x /setup.sh && \ 6 | /setup.sh 7 | 8 | ENTRYPOINT [ "/entrypoint.sh", "docker" ] 9 | -------------------------------------------------------------------------------- /docker/docker_build/3.6/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | COPY setup.sh /setup.sh 4 | 5 | RUN chmod +x /setup.sh && \ 6 | /setup.sh 7 | 8 | ENTRYPOINT [ "/entrypoint.sh", "docker" ] 9 | -------------------------------------------------------------------------------- /docker/docker_build/3.7/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | COPY setup.sh /setup.sh 4 | 5 | RUN chmod +x /setup.sh && \ 6 | /setup.sh 7 | 8 | ENTRYPOINT [ "/entrypoint.sh", "docker" ] 9 | -------------------------------------------------------------------------------- /docker/docker_build/2.7/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7 2 | 3 | ADD ./setup.sh /setup.sh 4 | 5 | RUN chmod +x /setup.sh && \ 6 | bash -c /setup.sh 7 | 8 | ENTRYPOINT [ "/entrypoint.sh", "docker" ] 9 | -------------------------------------------------------------------------------- /docker/docker_build/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /docker/docker_build/2.7/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /docker/docker_build/3.4/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /docker/docker_build/3.5/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /docker/docker_build/3.6/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /docker/docker_build/3.7/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | apt-get update 5 | 6 | # Module requirements 7 | pip install requests 8 | 9 | # Testing requiremenets 10 | pip install pycodestyle pylint nose coverage mock pytest 11 | 12 | # running in docker 13 | touch /docker 14 | -------------------------------------------------------------------------------- /sensu_plugin/compat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint: disable=undefined-variable 4 | """ 5 | Python 2/3 compatibility code. 6 | """ 7 | 8 | try: 9 | compat_basestring = basestring 10 | except NameError: # Python 3 11 | compat_basestring = (bytes, str) 12 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_pushevent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sensu_plugin.pushevent import push_event 4 | 5 | 6 | def test_push_event(): 7 | ''' 8 | tests the push_event method. 9 | ''' 10 | 11 | # test failure when no args are passed 12 | with pytest.raises(ValueError): 13 | push_event() 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Pull Request Checklist 2 | 3 | **Is this in reference to an existing issue?** 4 | 5 | #### General 6 | 7 | - [ ] Update Changelog following the conventions laid out [here](https://github.com/sensu-plugins/community/blob/master/HOW_WE_CHANGELOG.md) 8 | 9 | - [ ] Update README with any necessary configuration snippets 10 | 11 | - [ ] Binstubs are created if needed 12 | 13 | - [ ] Existing tests pass 14 | 15 | #### Purpose 16 | 17 | #### Known Compatibility Issues 18 | -------------------------------------------------------------------------------- /sensu_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | """This module provides helpers for writing Sensu plugins""" 2 | from sensu_plugin.plugin import SensuPlugin 3 | from sensu_plugin.check import SensuPluginCheck 4 | from sensu_plugin.metric import SensuPluginMetricGeneric 5 | from sensu_plugin.metric import SensuPluginMetricGraphite 6 | from sensu_plugin.metric import SensuPluginMetricInfluxdb 7 | from sensu_plugin.metric import SensuPluginMetricJSON 8 | from sensu_plugin.metric import SensuPluginMetricStatsd 9 | from sensu_plugin.handler import SensuHandler 10 | import sensu_plugin.pushevent 11 | -------------------------------------------------------------------------------- /sensu_plugin/exithook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | 6 | class ExitHook(object): 7 | def __init__(self): 8 | self._orig_exit = None 9 | self.exit_code = None 10 | self.exception = None 11 | 12 | def hook(self): 13 | self._orig_exit = sys.exit 14 | sys.exit = self.exit 15 | sys.excepthook = self.exc_handler 16 | 17 | def exit(self, code=0): 18 | self.exit_code = code 19 | self._orig_exit(code) 20 | 21 | def exc_handler(self, _exc_type, exc, *_args): 22 | self.exception = exc 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | .tox/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | sensu_plugin/tests/report 34 | 35 | # Translations 36 | *.mo 37 | 38 | # Mr Developer 39 | .mr.developer.cfg 40 | .project 41 | .pydevproject 42 | 43 | # Rope 44 | .ropeproject 45 | 46 | # Django stuff: 47 | *.log 48 | *.pot 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | MANIFEST 54 | 55 | 56 | # virtual environments 57 | venv 58 | virtualenv 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='sensu_plugin', 5 | version='0.8.0', 6 | author='Sensu-Plugins and Contributors', 7 | author_email='sensu-users@googlegroups.com', 8 | packages=['sensu_plugin', 'sensu_plugin.tests'], 9 | scripts=[], 10 | url='https://github.com/sensu-plugins/sensu-plugin-python', 11 | description='A framework for writing Python sensu plugins.', 12 | long_description=""" 13 | """, 14 | install_requires=[ 15 | 'argparse', 16 | 'requests' 17 | ], 18 | tests_require=[ 19 | 'pycodestyle', 20 | 'pylint', 21 | 'coverage', 22 | 'nose', 23 | 'pytest', 24 | 'mock' 25 | ], 26 | classifiers=[ 27 | 'Programming Language :: Python :: 2.7', 28 | "Programming Language :: Python :: 3", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | To help lint & check the contents of this repo, docker resources have been 4 | provided. 5 | 6 | The simplest way to run the tests is to use `docker-compose`. Within the 7 | docker directory, run `docker-compose run --rm `, where version is 8 | one of `2.7`,`3.4`,`3.5`,`3.6`. This will automatically build the docker 9 | images from the `docker_build` directory and run the `run_tests` script against 10 | the `sensu_plugin` directory within the container, removing the container 11 | upon exit. 12 | 13 | For any additional prerequisites that are needed (eg. python modules), 14 | amend `docker_build/setup.sh` and then run `docker_build/update` (This 15 | copies the amended `setup.sh` into each image directory). Proceed to rebuild 16 | the docker images, either via. 17 | `docker build -t python:2.7.14-sensuci ./docker_build/2.7` 18 | or with `docker-compose build 2.7`. 19 | 20 | If you are feeling adventurous, you can simply `docker-compose up` to build 21 | and launch all of the containers at once! 22 | -------------------------------------------------------------------------------- /sensu_plugin/pushevent.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import json 3 | 4 | 5 | def push_event(sensu_client_host='127.0.0.1', 6 | sensu_client_port=3030, 7 | source=None, 8 | check_name=None, 9 | exit_code=None, 10 | message=None, 11 | **extra_vars): 12 | 13 | for param in [source, check_name, exit_code, message]: 14 | if param is None: 15 | raise ValueError 16 | message = { 17 | "source": source, 18 | "name": check_name, 19 | "status": exit_code, 20 | "output": message, 21 | } 22 | for key in extra_vars: 23 | message[key] = extra_vars[key] 24 | try: 25 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 26 | except socket.error: 27 | sock.close() 28 | raise 29 | sock.connect((sensu_client_host, sensu_client_port)) 30 | sock.send(json.dumps(message)) 31 | if sock.recv(24) != "ok": 32 | sock.close() 33 | raise socket.error 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - '2.7' 5 | - '3.4' 6 | - '3.5' 7 | - '3.6' 8 | 9 | # magic to enable 3.7 without globally enabling sudo and dist: xenial for other build jobs 10 | matrix: 11 | include: 12 | - python: 3.7 13 | dist: xenial 14 | sudo: true 15 | 16 | install: 17 | - python setup.py install 18 | - pip install -r requirements_test.txt 19 | 20 | script: 21 | - ./run_tests 22 | 23 | deploy: 24 | provider: pypi 25 | user: sensu_plugins 26 | password: 27 | secure: l84nSfeFo2b4ZbOBC3HbH8MnYxV9PMO16HIgGs3goXsc7RFMgB6bXV0h/jf9vj1aoZeVe4VyKPQVaZ1XLGz/tF2rZfad+CaXdkzwqvTnU33YQNKu26yocANTucC72nnfwJ+WQMGVUr/jBejhRL+UI9tpf8DXkM4LshOiXMkCeH4ebhMHlQAxuxZZPkbnD4EkpHV9aS2prb+5xhPrZwBDeJO5yukA70dO2wlGsPSkwCig+kRVInbReOp1+74PsEa4Vhj/2YgRV6DfVt+Xn1Pi40neVRSrkE3RQVDVRT6Sx8J0SrYdj0jWqCFnKaPgCr5PL25F31x5JaqV3fGTmPE3wxhsUXuUXBaHcYhwqDJ1c2gffqiz7HtDD1KebJa4NijXbgXnXi7IdvIuB6DvSGpPQYaA3LuFRYnJZjrSmq09TkvM6JQme9hfHkSjbxP7M68hfYztnba7ouY9nPP9iXCIqKjgEpqtaSwL0cf0747xW3EG35u327MwfGOj6DeJEAaHxnl2a7xCCFX+yZBe/lGxmlbFJjHCj1N3UJG6G0wDLdGk/K7CAMXvpmsANoyMm/IOFqjUSbSCam+RvV0sqe+2Sthu+yUYVxHHYmOHtyLnHsB/kIXVHJO6p5b01ZsZD1wKWwd4wnyhRM4j76izwB+wIQ9WQebAbR0r4ISxMBkNbug= 28 | on: 29 | tags: true 30 | branch: master 31 | -------------------------------------------------------------------------------- /sensu_plugin/check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | from sensu_plugin.plugin import SensuPlugin 5 | 6 | 7 | class SensuPluginCheck(SensuPlugin): 8 | ''' 9 | Class that inherits from SensuPlugin. 10 | ''' 11 | def check_name(self, name=None): 12 | ''' 13 | Checks the plugin name and sets it accordingly. 14 | Uses name if specified, class name if not set. 15 | ''' 16 | if name: 17 | self.plugin_info['check_name'] = name 18 | 19 | if self.plugin_info['check_name'] is not None: 20 | return self.plugin_info['check_name'] 21 | 22 | return self.__class__.__name__ 23 | 24 | def message(self, *m): 25 | self.plugin_info['message'] = m 26 | 27 | def output(self, args): 28 | msg = '' 29 | if args is None or (args[0] is None and len(args) == 1): 30 | args = self.plugin_info['message'] 31 | 32 | if args is not None and not (args[0] is None and len(args) == 1): 33 | msg = ": {0}".format(' '.join(str(message) for message in args)) 34 | 35 | print("{0} {1}{2}".format(self.check_name(), 36 | self.plugin_info['status'], msg)) 37 | -------------------------------------------------------------------------------- /run_tests: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXIT=0 4 | 5 | rc() { 6 | if [ "$?" -ne 0 ]; then 7 | EXIT=1 8 | fi 9 | } 10 | 11 | [ -f /docker ] && cd sensu-plugin-python 12 | 13 | run_pycodestyle() { 14 | echo ' 15 | 16 | PYCODESTYLE -------------------------------------------------------------------' 17 | pycodestyle sensu_plugin 18 | rc 19 | } 20 | 21 | run_pylint() { 22 | echo ' 23 | 24 | PYLINT ------------------------------------------------------------------------' 25 | pylint --rcfile=pylint.rc sensu_plugin 26 | rc 27 | } 28 | 29 | run_nosetests() { 30 | echo ' 31 | 32 | NOSETESTS ---------------------------------------------------------------------' 33 | 34 | nosetests --with-coverage --cover-package=sensu_plugin \ 35 | --cover-min-percentage=80 --cover-tests --cover-erase \ 36 | --cover-html --cover-html-dir=./report -w sensu_plugin/tests \ 37 | --exe 38 | # --exe means to run all executable tests 39 | rc 40 | } 41 | 42 | case $1 in 43 | pycodestyle|style) 44 | run_pycodestyle 45 | ;; 46 | pylint|lint) 47 | run_pylint 48 | ;; 49 | nose|nosetests) 50 | run_nosetests 51 | ;; 52 | *) 53 | run_pycodestyle 54 | run_pylint 55 | run_nosetests 56 | ;; 57 | esac 58 | 59 | echo 60 | echo "Exiting with code $EXIT" 61 | exit $EXIT 62 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | services: 4 | "2.7": 5 | image: "python:2.7.14-sensuci" 6 | build: "./docker_build/2.7" 7 | hostname: "2.7" 8 | container_name: "sensuplugin_python2.7" 9 | volumes: 10 | - "../:/sensu-plugin-python/" 11 | - "../run_tests:/entrypoint.sh:ro" 12 | 13 | "3.4": 14 | image: "python:3.4-sensuci" 15 | build: "./docker_build/3.4" 16 | hostname: "3.4" 17 | container_name: "sensuplugin_python3.4" 18 | volumes: 19 | - "../:/sensu-plugin-python/" 20 | - "../run_tests:/entrypoint.sh:ro" 21 | 22 | "3.5": 23 | image: "python:3.5.4-sensuci" 24 | build: "./docker_build/3.5" 25 | hostname: "3.5" 26 | container_name: "sensuplugin_python3.5" 27 | volumes: 28 | - "../:/sensu-plugin-python/" 29 | - "../run_tests:/entrypoint.sh:ro" 30 | 31 | "3.6": 32 | image: "python:3.6-sensuci" 33 | build: "./docker_build/3.6" 34 | hostname: "3.6" 35 | container_name: "sensuplugin_python3.6" 36 | volumes: 37 | - "../:/sensu-plugin-python/" 38 | - "../run_tests:/entrypoint.sh:ro" 39 | 40 | "3.7": 41 | image: "python:3.7-sensuci" 42 | build: "./docker_build/3.7" 43 | hostname: "3.7" 44 | container_name: "sensuplugin_python3.7" 45 | volumes: 46 | - "../:/sensu-plugin-python/" 47 | - "../run_tests:/entrypoint.sh:ro" 48 | -------------------------------------------------------------------------------- /sensu_plugin/tests/example_configs.py: -------------------------------------------------------------------------------- 1 | def example_settings(): 2 | settings = ''' 3 | { 4 | "redis": { 5 | "reconnect_on_error": false, 6 | "auto_reconnect": true, 7 | "host": "redis", 8 | "db": 0, 9 | "port": 6379 10 | }, 11 | "api": { 12 | "bind": "0.0.0.0", 13 | "host": "api", 14 | "port": 4567 15 | }, 16 | "transport": { 17 | "reconnect_on_error": true, 18 | "name": "redis" 19 | }, 20 | "checks": {}, 21 | "handlers": {} 22 | } 23 | ''' 24 | 25 | return settings 26 | 27 | 28 | def example_check_result(): 29 | check_result = ''' 30 | { 31 | "id": "ef6b87d2-1f89-439f-8bea-33881436ab90", 32 | "action": "create", 33 | "timestamp": 1460172826, 34 | "occurrences": 2, 35 | "check": { 36 | "type": "standard", 37 | "total_state_change": 11, 38 | "history": ["0", "0", "1", "1", "2", "2"], 39 | "status": 2, 40 | "output": "No keepalive sent from client for 230 seconds (>=180)", 41 | "executed": 1460172826, 42 | "issued": 1460172826, 43 | "name": "keepalive", 44 | "thresholds": { 45 | "critical": 180, 46 | "warning": 120 47 | } 48 | }, 49 | "client": { 50 | "timestamp": 1460172596, 51 | "version": "1.1.0", 52 | "socket": { 53 | "port": 3030, 54 | "bind": "127.0.0.1" 55 | }, 56 | "subscriptions": [ 57 | "production" 58 | ], 59 | "environment": "development", 60 | "address": "127.0.0.1", 61 | "name": "client-01" 62 | } 63 | } 64 | ''' 65 | 66 | return check_result 67 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest.mock import Mock, patch 3 | except ImportError: 4 | from mock import Mock, patch 5 | 6 | try: 7 | from StringIO import StringIO 8 | except ImportError: 9 | from io import StringIO 10 | 11 | import pytest 12 | 13 | from sensu_plugin.plugin import SensuPlugin 14 | 15 | 16 | def mock_plugin_info(): 17 | return {'check_name': None, 'message': None, 'status': None} 18 | 19 | 20 | class TestSensuPlugin(object): 21 | def __init__(self): 22 | self.sensu_plugin = None 23 | 24 | def setup(self): 25 | ''' 26 | Instantiate a fresh SensuPlugin before each test. 27 | ''' 28 | self.sensu_plugin = SensuPlugin(autorun=False) 29 | 30 | def test_plugin_info(self): 31 | ''' 32 | Tests the values of plugin_info. 33 | ''' 34 | assert self.sensu_plugin.plugin_info == mock_plugin_info() 35 | 36 | def test_run_exit_code(self): 37 | ''' 38 | Tests the exit status of run. 39 | ''' 40 | with pytest.raises(SystemExit) as pytest_wrapped_e: 41 | self.sensu_plugin.run() 42 | assert pytest_wrapped_e.type == SystemExit 43 | assert pytest_wrapped_e.value.code == 1 44 | 45 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 46 | @patch('sys.stdout', new_callable=StringIO) 47 | def test_run_stdout(self, out): 48 | ''' 49 | Tests the the correct text values returns from run. 50 | ''' 51 | self.sensu_plugin.run() 52 | expected = ("SensuPlugin: Not implemented! " + 53 | "You should override SensuPlugin.run()") 54 | assert expected in out.getvalue() 55 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_check.py: -------------------------------------------------------------------------------- 1 | try: 2 | from unittest.mock import Mock, patch 3 | except ImportError: 4 | from mock import Mock, patch 5 | 6 | from sensu_plugin.check import SensuPluginCheck 7 | 8 | 9 | class TestSensuPluginCheck(object): 10 | def __init__(self): 11 | self.sensu_plugin_check = None 12 | 13 | def setup(self): 14 | ''' 15 | Instantiate a fresh SensuPluginCheck before each test. 16 | ''' 17 | self.sensu_plugin_check = SensuPluginCheck(autorun=False) 18 | 19 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 20 | def test_check_name(self): 21 | ''' 22 | Tests the check_name method. 23 | ''' 24 | 25 | # called without a name should be None 26 | self.sensu_plugin_check.check_name() 27 | self.sensu_plugin_check.ok() 28 | assert self.sensu_plugin_check.plugin_info['check_name'] is None 29 | 30 | # called with a name should set check_name 31 | self.sensu_plugin_check.check_name(name="checktest") 32 | self.sensu_plugin_check.ok() 33 | assert self.sensu_plugin_check.plugin_info['check_name'] == "checktest" 34 | 35 | def test_message(self): 36 | ''' 37 | Tests the message method. 38 | ''' 39 | # called without a message should return an empty tuple 40 | self.sensu_plugin_check.message() 41 | assert self.sensu_plugin_check.plugin_info['message'] == tuple() 42 | 43 | # called with a message should set message 44 | self.sensu_plugin_check.message("testing") 45 | assert self.sensu_plugin_check.plugin_info['message'] == ('testing',) 46 | 47 | def test_output(self): 48 | ''' 49 | Tests the output method. 50 | ''' 51 | self.sensu_plugin_check.output(args="test") 52 | -------------------------------------------------------------------------------- /sensu_plugin/tests/example_configs_utils.py: -------------------------------------------------------------------------------- 1 | def example_check_result_v2(): 2 | check_result = ''' 3 | { 4 | "entity": { 5 | "id": "test_entity", 6 | "subscriptions": [ 7 | "sub1", 8 | "sub2", 9 | "sub3" 10 | ] 11 | }, 12 | "check": { 13 | "name": "test_check", 14 | "output": "test_output", 15 | "subscriptions": [ 16 | "sub1", 17 | "sub2", 18 | "sub3" 19 | ], 20 | "proxy_entity_id": "test_proxy", 21 | "total_state_change": 4, 22 | "state":"failing", 23 | "history": [ 24 | { 25 | "status": 0, 26 | "executed": 0 27 | }, 28 | { 29 | "status": 1, 30 | "executed": 1 31 | }, 32 | { 33 | "status": 2, 34 | "executed": 2 35 | }, 36 | { 37 | "status": 3, 38 | "executed": 3 39 | }, 40 | { 41 | "status":0, 42 | "executed":4 43 | } 44 | ], 45 | "status": 0 46 | }, 47 | "occurrences": 1 48 | } 49 | ''' 50 | 51 | return check_result 52 | 53 | 54 | def example_check_result_v2_mapped(): 55 | check_result = ''' 56 | { 57 | "action": "create", 58 | "check": { 59 | "history": [ 60 | "0", 61 | "1", 62 | "2", 63 | "3", 64 | "0" 65 | ], 66 | "history_v2": [ 67 | { 68 | "executed": 0, 69 | "status": 0 70 | }, 71 | { 72 | "executed": 1, 73 | "status": 1 74 | }, 75 | { 76 | "executed": 2, 77 | "status": 2 78 | }, 79 | { 80 | "executed": 3, 81 | "status": 3 82 | }, 83 | { 84 | "executed": 4, 85 | "status": 0 86 | } 87 | ], 88 | "name": "test_check", 89 | "output": "test_output", 90 | "proxy_entity_id": "test_proxy", 91 | "source": "test_proxy", 92 | "state": "failing", 93 | "status": 0, 94 | "subscribers": [ 95 | "sub1", 96 | "sub2", 97 | "sub3" 98 | ], 99 | "subscriptions": [ 100 | "sub1", 101 | "sub2", 102 | "sub3" 103 | ], 104 | "total_state_change": 4 105 | }, 106 | "client": { 107 | "id": "test_entity", 108 | "name": "test_entity", 109 | "subscribers": [ 110 | "sub1", 111 | "sub2", 112 | "sub3" 113 | ], 114 | "subscriptions": [ 115 | "sub1", 116 | "sub2", 117 | "sub3" 118 | ] 119 | }, 120 | "entity": { 121 | "id": "test_entity", 122 | "name": "test_entity", 123 | "subscribers": [ 124 | "sub1", 125 | "sub2", 126 | "sub3" 127 | ], 128 | "subscriptions": [ 129 | "sub1", 130 | "sub2", 131 | "sub3" 132 | ] 133 | }, 134 | "occurrences": 1, 135 | "v2_event_mapped_into_v1": true 136 | } 137 | ''' 138 | return check_result 139 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | 4 | try: 5 | from unittest.mock import patch, mock_open 6 | except ImportError: 7 | from mock import patch, mock_open 8 | 9 | from sensu_plugin.tests.example_configs_utils import example_check_result_v2,\ 10 | example_check_result_v2_mapped 11 | 12 | from sensu_plugin.utils import map_v2_event_into_v1, get_settings 13 | 14 | 15 | EVENT = json.loads(example_check_result_v2()) 16 | EXPECTED = json.loads(example_check_result_v2_mapped()) 17 | 18 | 19 | def test_get_settings_no_files(): 20 | ''' 21 | Test the get settings method with no files. 22 | ''' 23 | with patch('os.listdir') as mocked_listdir: 24 | mocked_listdir.return_value = [] 25 | settings = get_settings() 26 | assert settings == {} 27 | 28 | 29 | @patch('os.path.isfile') 30 | @patch('os.path.isdir') 31 | def test_get_settings_with_file(mock_isfile, mock_isdir): 32 | ''' 33 | Test the get settings method with one file. 34 | ''' 35 | test_json = '{"test": { "key": "value"}}' 36 | mocked_open = mock_open(read_data=test_json) 37 | mock_isfile.return_value = True 38 | mock_isdir.return_value = True 39 | with patch('os.listdir') as mocked_listdir: 40 | try: 41 | with patch("builtins.open", mocked_open): 42 | mocked_listdir.return_value = ['/etc/sensu/conf.d/test.json'] 43 | settings = get_settings() 44 | assert settings == {'test': {'key': 'value'}} 45 | except ImportError: 46 | with patch("__builtin__.open", mocked_open): 47 | mocked_listdir.return_value = ['/etc/sensu/conf.d/test.json'] 48 | settings = get_settings() 49 | assert settings == {'test': {'key': 'value'}} 50 | 51 | 52 | def test_map_v2_into_v1_basic(): 53 | ''' 54 | Test the map_v2_event_into_v1 method with a basic event. 55 | ''' 56 | 57 | result = map_v2_event_into_v1(EVENT) 58 | assert result == EXPECTED 59 | 60 | 61 | def test_map_v2_into_v1_mapped(): 62 | ''' 63 | Test the map_v2_event_into_v1 method with a pre-mapped event. 64 | ''' 65 | 66 | result = map_v2_event_into_v1(EVENT) 67 | assert result == EVENT 68 | 69 | 70 | def test_map_v2_into_v1_nostate(): 71 | ''' 72 | Test the map_v2_event_into_v1 method with an event missing state. 73 | ''' 74 | event = json.loads(example_check_result_v2()) 75 | event['check'].pop('state', None) 76 | result = map_v2_event_into_v1(event) 77 | assert result['action'] == 'unknown::2.0_event' 78 | 79 | 80 | def test_map_v2_into_v1_history(): 81 | ''' 82 | Test the map_v2_event_into_v1 method with invalid history. 83 | ''' 84 | event = json.loads(example_check_result_v2()) 85 | event['check']['history'].append({u'status': 'broken', u'executed': 5}) 86 | result = map_v2_event_into_v1(event) 87 | assert result['check']['history'][5] == "3" 88 | -------------------------------------------------------------------------------- /sensu_plugin/plugin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import atexit 7 | import os 8 | import sys 9 | import traceback 10 | 11 | from collections import namedtuple 12 | 13 | from sensu_plugin.exithook import ExitHook 14 | 15 | # create a namedtuple of all valid exit codes 16 | ExitCode = namedtuple('ExitCode', ['OK', 'WARNING', 'CRITICAL', 'UNKNOWN']) 17 | 18 | 19 | class SensuPlugin(object): 20 | ''' 21 | Base class used by both checks and metrics plugins. 22 | ''' 23 | def __init__(self, autorun=True): 24 | 25 | self.plugin_info = { 26 | 'check_name': None, 27 | 'message': None, 28 | 'status': None 29 | } 30 | 31 | # create a method for each of the exit codes 32 | # and register as exiy functions 33 | self._hook = ExitHook() 34 | self._hook.hook() 35 | 36 | self.exit_code = ExitCode(0, 1, 2, 3) 37 | for field in self.exit_code._fields: 38 | self.__make_dynamic(field) 39 | 40 | atexit.register(self.__exitfunction) 41 | 42 | # Prepare command line arguments 43 | self.parser = argparse.ArgumentParser() 44 | if hasattr(self, 'setup'): 45 | self.setup() 46 | (self.options, self.remain) = self.parser.parse_known_args() 47 | 48 | if autorun: 49 | self.run() 50 | 51 | def output(self, args): 52 | ''' 53 | Print the output message. 54 | ''' 55 | print("SensuPlugin: {}".format(' '.join(str(a) for a in args))) 56 | 57 | def __make_dynamic(self, method): 58 | ''' 59 | Create a method for each of the exit codes. 60 | ''' 61 | def dynamic(*args): 62 | self.plugin_info['status'] = method 63 | if not args: 64 | args = None 65 | self.output(args) 66 | sys.exit(getattr(self.exit_code, method)) 67 | 68 | method_lc = method.lower() 69 | dynamic.__doc__ = "%s method" % method_lc 70 | dynamic.__name__ = method_lc 71 | setattr(self, dynamic.__name__, dynamic) 72 | 73 | def run(self): 74 | ''' 75 | Method should be overwritten by inherited classes. 76 | ''' 77 | self.warning("Not implemented! You should override SensuPlugin.run()") 78 | 79 | def __exitfunction(self): 80 | ''' 81 | Method called by exit hook, ensures that both an exit code and 82 | output is supplied, also catches errors. 83 | ''' 84 | if self._hook.exit_code is None and self._hook.exception is None: 85 | print("Check did not exit! You should call an exit code method.") 86 | sys.stdout.flush() 87 | os._exit(1) 88 | elif self._hook.exception: 89 | print("Check failed to run: %s, %s" % 90 | (sys.last_type, traceback.format_tb(sys.last_traceback))) 91 | sys.stdout.flush() 92 | os._exit(2) 93 | -------------------------------------------------------------------------------- /sensu_plugin/metric.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | import json 5 | import time 6 | from sensu_plugin.plugin import SensuPlugin 7 | from sensu_plugin.compat import compat_basestring 8 | 9 | 10 | class SensuPluginMetricGeneric(SensuPlugin): 11 | def sanitise_arguments(self, args): 12 | # check whether the arguments have been passed by a dynamic status code 13 | # or if the output method is being called directly 14 | # extract the required tuple if called using dynamic function 15 | if len(args) == 1 and isinstance(args[0], tuple): 16 | args = args[0] 17 | # check to see whether output is running after being called by an empty 18 | # dynamic function. 19 | if args[0] is None: 20 | pass 21 | # check to see whether output is running after being called by a 22 | # dynamic whilst containing a message. 23 | elif isinstance(args[0], Exception) or len(args) == 1: 24 | print(args[0]) 25 | else: 26 | return args 27 | 28 | 29 | class SensuPluginMetricGraphite(SensuPluginMetricGeneric): 30 | def output(self, *args): 31 | # sanitise the arguments 32 | args = self.sanitise_arguments(args) 33 | if args: 34 | # convert the arguments to a list 35 | args = list(args) 36 | # add the timestamp if required 37 | if len(args) < 3: 38 | args.append(None) 39 | if args[2] is None: 40 | args[2] = (int(time.time())) 41 | # produce the output 42 | print(" ".join(str(s) for s in args[0:3])) 43 | 44 | 45 | class SensuPluginMetricInfluxdb(SensuPluginMetricGeneric): 46 | def output(self, *args): 47 | # sanitise the arguments 48 | args = self.sanitise_arguments(args) 49 | if args: 50 | # determine whether a single value has been passed 51 | # as fields and if so give it a name. 52 | fields = args[1] 53 | if fields.isdigit(): 54 | fields = "value={}".format(args[1]) 55 | # append tags on to the measurement name if they exist 56 | measurement = args[0] 57 | if len(args) > 2: 58 | measurement = "{},{}".format(args[0], args[2]) 59 | # create a timestamp 60 | timestamp = int(time.time()) 61 | # produce the output 62 | print("{} {} {}".format(measurement, fields, timestamp)) 63 | 64 | 65 | class SensuPluginMetricJSON(SensuPluginMetricGeneric): 66 | def output(self, args): 67 | obj = args[0] 68 | if isinstance(obj, (Exception, compat_basestring)): 69 | print(obj[0]) 70 | elif isinstance(obj, (dict, list)): 71 | print(json.dumps(obj)) 72 | 73 | 74 | class SensuPluginMetricStatsd(SensuPluginMetricGeneric): 75 | def output(self, *args): 76 | # sanitise the arguments 77 | args = self.sanitise_arguments(args) 78 | if args: 79 | # convert the arguments to a list 80 | args = list(args) 81 | if len(args) < 3 or args[2] is None: 82 | stype = 'kv' 83 | else: 84 | stype = args[2] 85 | # produce the output 86 | print("|".join([":".join(str(s) for s in args[0:2]), stype])) 87 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_metric.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sensu_plugin import SensuPluginMetricGraphite 4 | from sensu_plugin import SensuPluginMetricInfluxdb 5 | from sensu_plugin import SensuPluginMetricJSON 6 | from sensu_plugin import SensuPluginMetricStatsd 7 | 8 | try: 9 | from unittest.mock import Mock, patch 10 | except ImportError: 11 | from mock import Mock, patch 12 | 13 | try: 14 | from StringIO import StringIO 15 | except ImportError: 16 | from io import StringIO 17 | 18 | 19 | class TestSensuPluginMetricGraphite(object): 20 | def __init__(self): 21 | self.sensu_plugin_metric = None 22 | 23 | def setup(self): 24 | ''' 25 | Instantiate a fresh SensuPluginMetricGraphite before each test. 26 | ''' 27 | self.sensu_plugin_metric = SensuPluginMetricGraphite(autorun=False) 28 | 29 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 30 | @patch('sys.stdout', new_callable=StringIO) 31 | def test_output_ok(self, out): 32 | self.sensu_plugin_metric.ok('sensu', 1) 33 | output = out.getvalue().split() 34 | assert output[0] == 'sensu' 35 | assert output[1] == '1' 36 | 37 | 38 | class TestSensuPluginMetricInfluxdb(object): 39 | def __init__(self): 40 | self.sensu_plugin_metric = None 41 | 42 | def setup(self): 43 | ''' 44 | Instantiate a fresh SensuPluginMetricInfluxdb before each test. 45 | ''' 46 | self.sensu_plugin_metric = SensuPluginMetricInfluxdb(autorun=False) 47 | 48 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 49 | @patch('sys.stdout', new_callable=StringIO) 50 | def test_output_ok(self, out): 51 | self.sensu_plugin_metric.ok('sensu', 'baz=42', 52 | 'env=prod,location=us-midwest') 53 | output = out.getvalue().split() 54 | assert output[0] == 'sensu,env=prod,location=us-midwest' 55 | assert output[1] == 'baz=42' 56 | 57 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 58 | @patch('sys.stdout', new_callable=StringIO) 59 | def test_output_ok_no_key(self, out): 60 | self.sensu_plugin_metric.ok('sensu', '42', 61 | 'env=prod,location=us-midwest') 62 | output = out.getvalue().split() 63 | assert output[0] == 'sensu,env=prod,location=us-midwest' 64 | assert output[1] == 'value=42' 65 | 66 | 67 | class TestSensuPluginMetricJSON(object): 68 | def __init__(self): 69 | self.sensu_plugin_metric = None 70 | 71 | def setup(self): 72 | ''' 73 | Instantiate a fresh SensuPluginMetricJSON before each test. 74 | ''' 75 | self.sensu_plugin_metric = SensuPluginMetricJSON(autorun=False) 76 | 77 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 78 | @patch('sys.stdout', new_callable=StringIO) 79 | def test_output_ok(self, out): 80 | self.sensu_plugin_metric.ok({'foo': 1, 'bar': 'anything'}) 81 | assert json.loads(out.getvalue()) 82 | 83 | 84 | class TestSensuPluginMetricStatsd(object): 85 | def __init__(self): 86 | self.sensu_plugin_metric = None 87 | 88 | def setup(self): 89 | ''' 90 | Instantiate a fresh SensuPluginMetricStatsd before each test. 91 | ''' 92 | self.sensu_plugin_metric = SensuPluginMetricStatsd(autorun=False) 93 | 94 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 95 | @patch('sys.stdout', new_callable=StringIO) 96 | def test_output_ok(self, out): 97 | self.sensu_plugin_metric.ok('sensu.baz', 42, 'g') 98 | assert out.getvalue() == "sensu.baz:42|g\n" 99 | 100 | @patch('sensu_plugin.plugin.sys.exit', Mock()) 101 | @patch('sys.stdout', new_callable=StringIO) 102 | def test_output_ok_two(self, out): 103 | self.sensu_plugin_metric.ok('sensu.baz', 42) 104 | assert out.getvalue() == "sensu.baz:42|kv\n" 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![sensu](https://raw.github.com/sensu/sensu/master/sensu-logo.png) 2 | 3 | # Python Sensu Plugin 4 | 5 | This is a framework for writing your own [Sensu](https://github.com/sensu/sensu) plugins in Python. 6 | It's not required to write a plugin (most Nagios plugins will work 7 | without modification); it just makes it easier. 8 | 9 | [![Build Status](https://travis-ci.org/sensu-plugins/sensu-plugin-python.png?branch=master)](https://travis-ci.org/sensu-plugins/sensu-plugin-python) 10 | 11 | ## Checks 12 | 13 | To implement your own check, subclass SensuPluginCheck, like 14 | this: 15 | 16 | from sensu_plugin import SensuPluginCheck 17 | 18 | class MyCheck(SensuPluginCheck): 19 | def setup(self): 20 | # Setup is called with self.parser set and is responsible for setting up 21 | # self.options before the run method is called 22 | 23 | self.parser.add_argument( 24 | '-w', 25 | '--warning', 26 | required=True, 27 | type=int, 28 | help='Integer warning level to output' 29 | ) 30 | self.parser.add_argument( 31 | '-m', 32 | '--message', 33 | default=None, 34 | help='Message to print' 35 | ) 36 | 37 | 38 | def run(self): 39 | # this method is called to perform the actual check 40 | 41 | self.check_name('my_awesome_check') # defaults to class name 42 | 43 | if self.options.warning == 0: 44 | self.ok(self.options.message) 45 | elif self.options.warning == 1: 46 | self.warning(self.options.message) 47 | elif self.options.warning == 2: 48 | self.critical(self.options.message) 49 | else: 50 | self.unknown(self.options.message) 51 | 52 | if __name__ == "__main__": 53 | f = MyCheck() 54 | 55 | ## Remote (JIT) Checks 56 | 57 | To submit checks on behalf of another system, import push_event: 58 | 59 | from sensu_plugin.pushevent import push_event 60 | 61 | Then use with: 62 | 63 | push_event(source="a_remote_host", check_name="MyCheckName", exit_code=2, message="My check has failed") 64 | 65 | This will submit a check result (a failure) appearing to come from the remote host 'a_remote_host', for check 'MyCheckName'. 66 | 67 | The default assumption is that there is a local sensu client running on port 3030, but you can override this by passing in sensu_client_host and sensu_client_port parameters. 68 | 69 | The check submits the check in json format. Arbitrary extra fields can be added, e.g. 70 | 71 | push_event(source="a_remote_host", check_name="MyCheckName", exit_code=2, message="My check has failed", team="MyTeam") 72 | 73 | ## Metrics 74 | 75 | For a metric you can subclass one of the following; 76 | 77 | * SensuPluginMetricGraphite 78 | * SensuPluginMetricInfluxdb 79 | * SensuPluginMetricJSON 80 | * SensuPluginMetricStatsd 81 | 82 | ### Graphite 83 | 84 | from sensu_plugin import SensuPluginMetricGraphite 85 | 86 | class MyGraphiteMetric (SensuPluginMetricGraphite): 87 | def run(self): 88 | self.ok('sensu', 1) 89 | 90 | if __name__ == "__main__": 91 | metric = MyGraphiteMetric() 92 | 93 | ### Influxdb 94 | 95 | from sensu_plugin import SensuPluginMetricInfluxdb 96 | 97 | class MyInfluxdbMetric (SensuPluginMetricInfluxdb): 98 | def run(self): 99 | self.ok('sensu', 'baz=42', 'env=prod,location=us-midwest') 100 | 101 | if __name__ == "__main__": 102 | metric = MyInfluxdbMetric() 103 | 104 | ### JSON 105 | 106 | from sensu_plugin import SensuPluginMetricJSON 107 | 108 | class MyJSONMetric(OLDSensuPluginMetricJSON): 109 | def run(self): 110 | self.ok({'foo': 1, 'bar': 'anything'}) 111 | 112 | if __name__ == "__main__": 113 | metric = MyJSONMetric() 114 | 115 | ### StatsD 116 | 117 | from sensu_plugin import SensuPluginMetricStatsd 118 | 119 | class MyStatsdMetric(SensuPluginMetricStatsd): 120 | def run(self): 121 | self.ok('sensu.baz', 42, 'g') 122 | 123 | if __name__ == "__main__": 124 | metric = MyStatsdMetric() 125 | 126 | ## License 127 | 128 | * Based heavily on [sensu-plugin](https://github.com/sensu/sensu-plugin) Copyright 2011 Decklin Foster 129 | * Python port Copyright 2014 S. Zachariah Sprackett 130 | 131 | Released under the same terms as Sensu (the MIT license); see LICENSE 132 | for details 133 | -------------------------------------------------------------------------------- /sensu_plugin/utils.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Utilities for loading config files, etc. 3 | ''' 4 | import os 5 | import json 6 | 7 | from copy import deepcopy 8 | 9 | 10 | def config_files(): 11 | ''' 12 | Get list of currently used config files. 13 | ''' 14 | sensu_loaded_tempfile = os.environ.get('SENSU_LOADED_TEMPFILE') 15 | sensu_config_files = os.environ.get('SENSU_CONFIG_FILES') 16 | sensu_v1_config = '/etc/sensu/config.json' 17 | sensu_v1_confd = '/etc/sensu/conf.d' 18 | if sensu_loaded_tempfile and os.path.isfile(sensu_loaded_tempfile): 19 | with open(sensu_loaded_tempfile, 'r') as tempfile: 20 | contents = tempfile.read() 21 | return contents.split(':') 22 | elif sensu_config_files: 23 | return sensu_config_files.split(':') 24 | else: 25 | files = [] 26 | filenames = [] 27 | if os.path.isfile(sensu_v1_config): 28 | files = [sensu_v1_config] 29 | if os.path.isdir(sensu_v1_confd): 30 | filenames = [f for f in os.listdir(sensu_v1_confd) 31 | if os.path.splitext(f)[1] == '.json'] 32 | for filename in filenames: 33 | files.append('{}/{}'.format(sensu_v1_confd, filename)) 34 | return files 35 | 36 | 37 | def get_settings(): 38 | ''' 39 | Get all currently loaded settings. 40 | ''' 41 | settings = {} 42 | for config_file in config_files(): 43 | config_contents = load_config(config_file) 44 | if config_contents is not None: 45 | settings = deep_merge(settings, config_contents) 46 | return settings 47 | 48 | 49 | def load_config(filename): 50 | ''' 51 | Read contents of config file. 52 | ''' 53 | try: 54 | with open(filename, 'r') as config_file: 55 | return json.loads(config_file.read()) 56 | except IOError: 57 | pass 58 | 59 | 60 | def deep_merge(dict_one, dict_two): 61 | ''' 62 | Deep merge two dicts. 63 | ''' 64 | merged = dict_one.copy() 65 | for key, value in dict_two.items(): 66 | # value is equivalent to dict_two[key] 67 | if (key in dict_one and 68 | isinstance(dict_one[key], dict) and 69 | isinstance(value, dict)): 70 | merged[key] = deep_merge(dict_one[key], value) 71 | elif (key in dict_one and 72 | isinstance(dict_one[key], list) and 73 | isinstance(value, list)): 74 | merged[key] = list(set(dict_one[key] + value)) 75 | else: 76 | merged[key] = value 77 | return merged 78 | 79 | 80 | def map_v2_event_into_v1(event): 81 | ''' 82 | Helper method to convert Sensu 2.x event into Sensu 1.x event. 83 | ''' 84 | 85 | # return the event if it has already been mapped 86 | if "v2_event_mapped_into_v1" in event: 87 | return event 88 | 89 | # Trigger mapping code if enity exists and client does not 90 | if not bool(event.get('client')) and "entity" in event: 91 | event['client'] = event['entity'] 92 | 93 | # Fill in missing client attributes 94 | if "name" not in event['client']: 95 | event['client']['name'] = event['entity']['id'] 96 | 97 | if "subscribers" not in event['client']: 98 | event['client']['subscribers'] = event['entity']['subscriptions'] 99 | 100 | # Fill in renamed check attributes expected in 1.4 event 101 | if "subscribers" not in event['check']: 102 | event['check']['subscribers'] = event['check']['subscriptions'] 103 | 104 | if "source" not in event['check']: 105 | event['check']['source'] = event['check']['proxy_entity_id'] 106 | 107 | # Mimic 1.4 event action based on 2.0 event state 108 | # action used in logs and fluentd plugins handlers 109 | action_state_mapping = {'flapping': 'flapping', 'passing': 'resolve', 110 | 'failing': 'create'} 111 | 112 | if "state" in event['check']: 113 | state = event['check']['state'] 114 | else: 115 | state = "unknown::2.0_event" 116 | 117 | if "action" not in event and state.lower() in action_state_mapping: 118 | event['action'] = action_state_mapping[state.lower()] 119 | else: 120 | event['action'] = state 121 | 122 | # Mimic 1.4 event history based on 2.0 event history 123 | if "history" in event['check']: 124 | # save the original history 125 | event['check']['history_v2'] = deepcopy(event['check']['history']) 126 | legacy_history = [] 127 | for history in event['check']['history']: 128 | if isinstance(history['status'], int): 129 | legacy_history.append(str(history['status'])) 130 | else: 131 | legacy_history.append("3") 132 | 133 | event['check']['history'] = legacy_history 134 | 135 | # Setting flag indicating this function has already been called 136 | event['v2_event_mapped_into_v1'] = True 137 | 138 | # return the updated event 139 | return event 140 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | This CHANGELOG follows the format listed [here](https://github.com/sensu-plugins/community/blob/master/HOW_WE_CHANGELOG.md) 5 | 6 | # [Unreleased] 7 | 8 | # [0.8.0] 9 | ### Added 10 | 11 | - Add new class SensuPluginMetricsGeneric, this will be extended in future to act in a similar way to its Ruby counterpart. (@borourke) 12 | - Added a new class SensuPluginMetricInfluxdb, which outputs the results in influxdb line format. (@borourke) 13 | - Add basic tests for the Metrics classes. (@borourke) 14 | 15 | ### Fixed 16 | 17 | - Refactor metrics classes, they should now function properly when passed an exception, empty status or a status message. (@borourke) 18 | - Update tests so that they run with pytest > 4.0. (@borourke) 19 | - Updated setup.py to include classifiers as per https://packaging.python.org/tutorials/packaging-projects. (@borourke) 20 | 21 | # [0.7.1] 22 | ### Added 23 | - Make get_settings() sensu 2.0 compatible. (@barryorourke) 24 | 25 | # [0.7.0] 26 | ## Added 27 | - Add more testing to utils, bringing coverage up to 80%. (@barryorourke) 28 | 29 | # [0.6.0] 30 | ## Added 31 | - Added map_v2_event_into_v1 method to Utils for all plugin classes to use. (@barryorourke) 32 | - Added --map-v2-event-into-v1 runtime commandline option to base Handler class. (@barryorourke) 33 | - Alternatively set envvar SENSU_MAP_V2_EVENT_INTO_V1=1 and handlers will automatically attempt to map 2.x event data. (@barryorourke) 34 | - Mapping function sets and checks for boolean event attribute 'v2_event_mapped_into_v1', to prevent mapping from running multiple times in same pipeline. (@barryorourke) 35 | 36 | # [0.5.2] 37 | ## Added 38 | - test that event data exists and is valid json (@barryorourke) 39 | - make testing plugins a lot easier (@barryorourke) 40 | - add docstrings to plugin.py (@barryorourke) 41 | - add tests for plugin.py (@barryorourke) 42 | - add docstrings to check.py (@barryorourke) 43 | - add tests to check.py (@barryorourke) 44 | - add basic tests for push events (@barryorourke) 45 | 46 | # [0.5.1] 47 | ## Fixed 48 | - fix event reading into the handler (@barryorourke) 49 | 50 | # [0.5.0] 51 | ## Changed 52 | - Remove unused tests (@barryorourke) 53 | - Refactor the run_tests script (@absolutejam) 54 | - Add support for Python 3.7 (@barryorourke) 55 | - Update docker to use refactored test suite (@barryorourke) 56 | - Add python 3.7 support to docker (@barryorourke) 57 | - rename test directory to tests, because OCD (@barryorourke) 58 | 59 | # [0.4.7] 60 | ## Added 61 | - handlers can now process commandline arguments (@barryorourke) 62 | 63 | # [0.4.5] 64 | ## Fixed 65 | - fix read event exception raise (@oboukili) 66 | 67 | # [0.4.4] 68 | ## Fixed 69 | - Fixes a bug introduced to `utils.config_files` which only returns `/etc/sensu/config.json` (@barryorourke) 70 | 71 | # [0.4.3] 72 | ## Fixed 73 | - Fixes `utils.config_files` so that it returns a list of files, rather than a list of `None`'s (@barryorourke) 74 | 75 | # [0.4.2] 76 | ## Fixed 77 | - Fixes `client_name` in `bail()` as it was using an incorrect path within `event` dict (@absolutejam) 78 | 79 | # [0.4.1] 80 | ## Fixed 81 | - Fixes `get_api_settings` method (@absolutejam) 82 | - Add missing dependeny to setup.py (@barryorourke) 83 | 84 | ## Changed 85 | - Move utils sub-package into the main package (@barryorourke) 86 | 87 | ## [0.4.0] 88 | ### Added 89 | - Add support for python 3.5, which is the default version in Debian 9. (@barryorourke) 90 | - Added Dockerfiles and docker-compose.yml to aid with local development & testing (@absolutejam) 91 | - Add handler support! (@absolutejam) 92 | - Temporarily drop test coverage percentage (@barryorourke) 93 | 94 | ## [0.3.2] 2017-10-10 95 | ### Fixed 96 | - Variable name changes in the metrics classed missed during the initial 0.3.0 release (@barryorourke) 97 | 98 | ## [0.3.1] 2017-10-10 99 | ### Fixed 100 | - Really obvious logical error introduced whilst making 0.3.0 pass tests (@barryorourke) 101 | 102 | ## [0.3.0] 2017-10-06 103 | ### Breaking Change 104 | - Dropped support for Python 3.3 (@barryorourke) 105 | 106 | ### Added 107 | - Added ability to submit checks for a jit host (@PhilipHarries) 108 | - Added support for Python 3.6 (@barryorourke) 109 | 110 | ### Changed 111 | - Update Changelog to comply with standards (@barryorourke) 112 | - Update Ownership in setup.py (@barryorourke) 113 | 114 | ## [0.2.0] 2014-01-06 115 | - Add support for Python3 (@zsprackett) 116 | 117 | ## [0.1.0] 2014-01-06 118 | - Initial release (@zsprackett) 119 | 120 | [Unreleased]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.8.0...HEAD 121 | [0.8.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.7.1...0.8.0 122 | [0.7.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.7.0...0.7.1 123 | [0.7.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.6.0...0.7.0 124 | [0.6.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.5.2...0.6.0 125 | [0.5.2]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.5.1...0.5.2 126 | [0.5.1]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.5.0...0.5.1 127 | [0.5.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.7...0.5.0 128 | [0.4.7]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.6...0.4.7 129 | [0.4.5]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.5...0.4.6 130 | [0.4.4]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.4...0.4.5 131 | [0.4.3]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.3...0.4.4 132 | [0.4.2]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.2...0.4.3 133 | [0.4.1]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.1...0.4.2 134 | [0.4.1]: https://github.com/sensu-plugins/sensu-plugin-python/compare/0.4.0...0.4.1 135 | [0.4.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/8920afcda62b34e9134ba9a816582dbf5f52806c...0.4.0 136 | [0.3.2]: https://github.com/sensu-plugins/sensu-plugin-python/compare/40314082947208acf9ed7c6d6c321ea52a14e765...8920afcda62b34e9134ba9a816582dbf5f52806c 137 | [0.3.1]: https://github.com/sensu-plugins/sensu-plugin-python/compare/2deaf3a34cd86afe13af9ab34aefd8056d284e85...40314082947208acf9ed7c6d6c321ea52a14e765 138 | [0.3.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/1302599c366ce30e04119bbc7551a258b33a7eab...2deaf3a34cd86afe13af9ab34aefd8056d284e85 139 | [0.2.0]: https://github.com/sensu-plugins/sensu-plugin-python/compare/7f3a6311771469ef1a38719a9dfb407f1ff43cf8...1302599c366ce30e04119bbc7551a258b33a7eab 140 | [0.1.0]: https://github.com/sensu-plugins/sensu-plugin-python/commit/7f3a6311771469ef1a38719a9dfb407f1ff43cf8 141 | -------------------------------------------------------------------------------- /sensu_plugin/tests/test_handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import os 4 | 5 | try: 6 | from unittest.mock import Mock, patch 7 | except ImportError: 8 | from mock import Mock, patch 9 | 10 | try: 11 | from StringIO import StringIO 12 | except ImportError: 13 | from io import StringIO 14 | 15 | import pytest 16 | 17 | # Alter path and import modules 18 | from sensu_plugin.handler import SensuHandler 19 | from sensu_plugin.tests.example_configs import example_check_result 20 | from sensu_plugin.tests.example_configs import example_settings 21 | 22 | # Currently just a single example check result 23 | CHECK_RESULT = example_check_result() 24 | CHECK_RESULT_DICT = json.loads(CHECK_RESULT) 25 | SETTINGS = example_settings() 26 | SETTINGS_DICT = json.loads(SETTINGS) 27 | 28 | 29 | def mock_api_settings(): 30 | return {'host': "http://api", 31 | 'port': 4567, 32 | 'user': None, 33 | 'password': None} 34 | 35 | 36 | class TestSensuHandler(object): 37 | def __init__(self): 38 | self.sensu_handler = None 39 | 40 | def setup(self): 41 | ''' 42 | Instantiate a fresh SensuHandler before each test. 43 | ''' 44 | self.sensu_handler = SensuHandler(autorun=False) # noqa 45 | 46 | @patch.object(SensuHandler, 'get_api_settings', Mock()) 47 | @patch('sensu_plugin.handler.get_settings', Mock()) 48 | def test_run(self): 49 | ''' 50 | Tests the run method. 51 | ''' 52 | 53 | # event should be valid json 54 | with patch('sensu_plugin.handler.sys.stdin') as mocked_stdin: 55 | mocked_stdin.read = lambda: CHECK_RESULT 56 | self.sensu_handler.run() 57 | assert self.sensu_handler.event == json.loads(CHECK_RESULT) 58 | 59 | @patch('sys.stdout', new_callable=StringIO) 60 | def test_handle(self, out): 61 | ''' 62 | Tests the handle method. 63 | ''' 64 | self.sensu_handler.handle() 65 | assert out.getvalue() == "ignoring event -- no handler defined.\n" 66 | 67 | def test_read_stdin(self): 68 | ''' 69 | Tests the read_stdin method. 70 | ''' 71 | # read_stdin should read something from stdin 72 | with patch('sensu_plugin.handler.sys.stdin') as mocked_stdin: 73 | mocked_stdin.read = None 74 | with pytest.raises(ValueError): 75 | self.sensu_handler.read_stdin() 76 | 77 | @patch.object(SensuHandler, 'read_stdin', CHECK_RESULT) 78 | def test_read_event(self): 79 | ''' 80 | Tests the read_event method. 81 | ''' 82 | 83 | read_event = self.sensu_handler.read_event 84 | 85 | # Test with example check 86 | assert isinstance(read_event(CHECK_RESULT), dict) 87 | 88 | # Ensure that the 'client' key is present 89 | assert isinstance(read_event(CHECK_RESULT)['client'], dict) 90 | 91 | # Ensure that the 'check' key is present 92 | assert isinstance(read_event(CHECK_RESULT)['check'], dict) 93 | 94 | # Test with a string (Fail) 95 | with pytest.raises(Exception): 96 | read_event('astring') 97 | 98 | @patch.object(SensuHandler, 'filter_disabled', Mock()) 99 | @patch.object(SensuHandler, 'filter_silenced', Mock()) 100 | @patch.object(SensuHandler, 'filter_dependencies', Mock()) 101 | @patch.object(SensuHandler, 'filter_repeated', Mock()) 102 | def test_filter(self): 103 | ''' 104 | Tests the filter method. 105 | ''' 106 | self.sensu_handler.event = {'check': {}} 107 | dfe = 'sensu_plugin.handler.SensuHandler.deprecated_filtering_enabled' 108 | dof = ('sensu_plugin.handler.SensuHandler' + 109 | '.deprecated_occurrence_filtering') 110 | with patch(dfe) as deprecated_filtering_enabled: 111 | deprecated_filtering_enabled.return_value = True 112 | 113 | with patch(dof) as deprecated_occurrence_filtering: 114 | deprecated_occurrence_filtering.return_value = True 115 | 116 | self.sensu_handler.filter() 117 | 118 | def test_occurrence_filtering(self): # noqa 119 | ''' 120 | Tests the deprecated_occurrence_filtering method. 121 | ''' 122 | 123 | self.sensu_handler.event = { 124 | 'check': { 125 | 'enable_deprecated_occurrence_filtering': True 126 | } 127 | } 128 | assert self.sensu_handler.deprecated_occurrence_filtering() 129 | 130 | self.sensu_handler.event = { 131 | 'check': {} 132 | } 133 | assert not self.sensu_handler.deprecated_occurrence_filtering() 134 | 135 | @patch.dict(os.environ, {'SENSU_API_URL': "http://api:4567"}) 136 | def test_get_api_settings(self): 137 | ''' 138 | Tests the get_api_settings method. 139 | ''' 140 | 141 | assert self.sensu_handler.get_api_settings() == mock_api_settings() 142 | 143 | @patch.object(SensuHandler, 'api_request') 144 | def test_stash_exists(self, mock_api_request): 145 | ''' 146 | Tests the stash_exists method. 147 | ''' 148 | 149 | class RequestsMock(object): 150 | def __init__(self, ret): 151 | self.status_code = ret 152 | 153 | # Mock stash exists 154 | mock_api_request.return_value = RequestsMock(200) 155 | assert self.sensu_handler.stash_exists('stash') 156 | 157 | # Mock stash missing 158 | mock_api_request.return_value = RequestsMock(404) 159 | assert not self.sensu_handler.stash_exists('stash') 160 | 161 | @patch('sensu_plugin.handler.requests.post') 162 | @patch('sensu_plugin.handler.requests.get') 163 | def test_api_request(self, mock_get, mock_post): 164 | ''' 165 | Tests the api_request method. 166 | ''' 167 | 168 | # No api_settings defined 169 | with pytest.raises(AttributeError): 170 | self.sensu_handler.api_request('GET', 'foo') 171 | for mock_method, method in [(mock_get, 'GET'), (mock_post, 'POST')]: 172 | # Should not supply auth 173 | self.sensu_handler.api_settings = { 174 | 'host': 'http://api', 175 | 'port': 4567 176 | } 177 | self.sensu_handler.api_request(method, 'foo') 178 | mock_method.assert_called_with('http://api:4567/foo', auth=()) 179 | # Should still not supply any auth as it requires password too 180 | self.sensu_handler.api_settings['user'] = 'mock_user' 181 | self.sensu_handler.api_request(method, 'foo') 182 | mock_method.assert_called_with('http://api:4567/foo', auth=()) 183 | 184 | # Should supply auth 185 | self.sensu_handler.api_settings['password'] = 'mock_pass' 186 | self.sensu_handler.api_request(method, 'foo') 187 | mock_method.assert_called_with('http://api:4567/foo', 188 | auth=('mock_user', 'mock_pass')) 189 | -------------------------------------------------------------------------------- /pylint.rc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | init-hook='import sys; sys.path.append("../..")' 10 | 11 | # Profiled execution. 12 | profile=no 13 | 14 | # Add files or directories to the blacklist. They should be base names, not 15 | # paths. 16 | ignore=CVS 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | 26 | [MESSAGES CONTROL] 27 | 28 | # Enable the message, report, category or checker with the given id(s). You can 29 | # either give multiple identifier separated by comma (,) or put this option 30 | # multiple time. 31 | #enable= 32 | 33 | # Disable the message, report, category or checker with the given id(s). You 34 | # can either give multiple identifier separated by comma (,) or put this option 35 | # multiple time (only on the command line, not in the configuration file where 36 | # it should appear only once). 37 | # R0201: *Method could be a function* 38 | # C0111: *Missing doc string* 39 | # R0903: *Too few public methods* 40 | # E0602: *Locally disabling undefined-variable* 41 | # R0204: *Redefinition of type from X to Y* 42 | # W0221: arguments-differ 43 | # BOR-TODO: look into W0211 more 44 | # BOR-TODO: remove R0205 when we drop python 2.7 support 45 | disable=E1101,R0201,W0212,C0111,R0903,E0602,R0204,W0221,R0205,R0912,R0801,inconsistent-return-statements 46 | 47 | 48 | 49 | [REPORTS] 50 | 51 | # Set the output format. Available formats are text, parseable, colorized, msvs 52 | # (visual studio) and html. You can also give a reporter class, eg 53 | # mypackage.mymodule.MyReporterClass. 54 | output-format=text 55 | 56 | # Put messages in a separate file for each module / package specified on the 57 | # command line instead of printing them on stdout. Reports (if any) will be 58 | # written in a file name "pylint_global.[txt|html]". 59 | files-output=no 60 | 61 | # Tells whether to display a full report or only the messages 62 | reports=yes 63 | 64 | # Python expression which should return a note less than 10 (10 is the highest 65 | # note). You have access to the variables errors warning, statement which 66 | # respectively contain the number of errors / warnings messages and the total 67 | # number of statements analyzed. This is used by the global evaluation report 68 | # (RP0004). 69 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 70 | 71 | # Add a comment according to your evaluation note. This is used by the global 72 | # evaluation report (RP0004). 73 | comment=no 74 | 75 | 76 | [MISCELLANEOUS] 77 | 78 | # List of note tags to take in consideration, separated by a comma. 79 | notes=FIXME,XXX,TODO 80 | 81 | 82 | [SIMILARITIES] 83 | 84 | # Minimum lines number of a similarity. 85 | min-similarity-lines=4 86 | 87 | # Ignore comments when computing similarities. 88 | ignore-comments=yes 89 | 90 | # Ignore docstrings when computing similarities. 91 | ignore-docstrings=yes 92 | 93 | # Ignore imports when computing similarities. 94 | ignore-imports=yes 95 | 96 | 97 | [BASIC] 98 | 99 | # Required attributes for module, separated by a comma 100 | required-attributes= 101 | 102 | # List of builtins function names that should not be used, separated by a comma 103 | #bad-functions=map,filter,apply,input 104 | bad-functions=map,apply,input 105 | 106 | # Regular expression which should only match correct module names 107 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 108 | 109 | # Regular expression which should only match correct module level names 110 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 111 | 112 | # Regular expression which should only match correct class names 113 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 114 | 115 | # Regular expression which should only match correct function names 116 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 117 | 118 | # Regular expression which should only match correct method names 119 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 120 | 121 | # Regular expression which should only match correct instance attribute names 122 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 123 | 124 | # Regular expression which should only match correct argument names 125 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 126 | 127 | # Regular expression which should only match correct variable names 128 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 129 | 130 | # Regular expression which should only match correct list comprehension / 131 | # generator expression variable names 132 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 133 | 134 | # Good variable names which should always be accepted, separated by a comma 135 | good-names=i,j,k,ex,Run,_,compat_basestring 136 | 137 | # Bad variable names which should always be refused, separated by a comma 138 | bad-names=foo,bar,baz,toto,tutu,tata 139 | 140 | # Regular expression which should only match functions or classes name which do 141 | # not require a docstring 142 | no-docstring-rgx=__.*__ 143 | 144 | 145 | [FORMAT] 146 | 147 | # Maximum number of characters on a single line. 148 | max-line-length=80 149 | 150 | # Maximum number of lines in a module 151 | max-module-lines=1000 152 | 153 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 154 | # tab). 155 | indent-string=' ' 156 | 157 | 158 | [TYPECHECK] 159 | 160 | # Tells whether missing members accessed in mixin class should be ignored. A 161 | # mixin class is detected if its name ends with "mixin" (case insensitive). 162 | ignore-mixin-members=yes 163 | 164 | # List of classes names for which member attributes should not be checked 165 | # (useful for classes with attributes dynamically set). 166 | ignored-classes=SQLObject 167 | 168 | # When zope mode is activated, add a predefined set of Zope acquired attributes 169 | # to generated-members. 170 | zope=no 171 | 172 | # List of members which are set dynamically and missed by pylint inference 173 | # system, and so shouldn't trigger E0201 when accessed. Python regular 174 | # expressions are accepted. 175 | generated-members=REQUEST,acl_users,aq_parent 176 | 177 | 178 | [VARIABLES] 179 | 180 | # Tells whether we should check for unused import in __init__ files. 181 | init-import=no 182 | 183 | # A regular expression matching the beginning of the name of dummy variables 184 | # (i.e. not used). 185 | dummy-variables-rgx=_|dummy 186 | 187 | # List of additional names supposed to be defined in builtins. Remember that 188 | # you should avoid to define new builtins when possible. 189 | additional-builtins= 190 | 191 | 192 | [CLASSES] 193 | 194 | # List of interface methods to ignore, separated by a comma. This is used for 195 | # instance to not check methods defines in Zope's Interface base class. 196 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 197 | 198 | # List of method names used to declare (i.e. assign) instance attributes. 199 | defining-attr-methods=__init__,__new__,setUp 200 | 201 | # List of valid names for the first argument in a class method. 202 | valid-classmethod-first-arg=cls 203 | 204 | # List of valid names for the first argument in a metaclass class method. 205 | valid-metaclass-classmethod-first-arg=mcs 206 | 207 | 208 | [IMPORTS] 209 | 210 | # Deprecated modules which should not be used, separated by a comma 211 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec 212 | 213 | # Create a graph of every (i.e. internal and external) dependencies in the 214 | # given file (report RP0402 must not be disabled) 215 | import-graph= 216 | 217 | # Create a graph of external dependencies in the given file (report RP0402 must 218 | # not be disabled) 219 | ext-import-graph= 220 | 221 | # Create a graph of internal dependencies in the given file (report RP0402 must 222 | # not be disabled) 223 | int-import-graph= 224 | 225 | 226 | [DESIGN] 227 | 228 | # Maximum number of arguments for function / method 229 | max-args=6 230 | 231 | # Argument names that match this expression will be ignored. Default to name 232 | # with leading underscore 233 | ignored-argument-names=_.* 234 | 235 | # Maximum number of locals for function / method body 236 | max-locals=20 237 | 238 | # Maximum number of return / yield for function / method body 239 | max-returns=6 240 | 241 | # Maximum number of branch for function / method body 242 | max-branchs=12 243 | 244 | # Maximum number of statements in function / method body 245 | max-statements=50 246 | 247 | # Maximum number of parents for a class (see R0901). 248 | max-parents=7 249 | 250 | # Maximum number of attributes for a class (see R0902). 251 | max-attributes=7 252 | 253 | # Minimum number of public methods for a class (see R0903). 254 | min-public-methods=2 255 | 256 | # Maximum number of public methods for a class (see R0904). 257 | max-public-methods=20 258 | 259 | 260 | [EXCEPTIONS] 261 | 262 | # Exceptions that will emit a warning when being caught. Defaults to 263 | # "Exception" 264 | overgeneral-exceptions=Exception 265 | -------------------------------------------------------------------------------- /sensu_plugin/handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | ''' 4 | This provides a base SensuHandler class that can be used for writing 5 | python-based Sensu handlers. 6 | ''' 7 | 8 | from __future__ import print_function 9 | import argparse 10 | import os 11 | import sys 12 | import json 13 | import requests 14 | try: 15 | from urlparse import urlparse 16 | except ImportError: 17 | from urllib.parse import urlparse 18 | from sensu_plugin.utils import get_settings, map_v2_event_into_v1 19 | 20 | 21 | class SensuHandler(object): 22 | ''' 23 | Base class for Sensu Handlers. 24 | ''' 25 | 26 | def __init__(self, autorun=True): 27 | 28 | if autorun: 29 | self.run() 30 | 31 | def run(self): 32 | ''' 33 | Set up the event object, global settings and command line 34 | arguments. 35 | ''' 36 | 37 | # Parse the stdin into a global event object 38 | stdin = self.read_stdin() 39 | self.event = self.read_event(stdin) 40 | 41 | # Prepare global settings 42 | self.settings = get_settings() 43 | self.api_settings = self.get_api_settings() 44 | 45 | # Prepare command line arguments and 46 | self.parser = argparse.ArgumentParser() 47 | 48 | # set up the 2.x to 1.x event mapping argument 49 | self.parser.add_argument("--map-v2-event-into-v1", 50 | action="store_true", 51 | default=False, 52 | dest="v2event") 53 | 54 | if hasattr(self, 'setup'): 55 | self.setup() 56 | (self.options, self.remain) = self.parser.parse_known_args() 57 | 58 | # map the event if required 59 | if (self.options.v2event or 60 | os.environ.get("SENSU_MAP_V2_EVENT_INTO_V1")): 61 | self.event = map_v2_event_into_v1(self.event) 62 | 63 | # Filter (deprecated) and handle 64 | self.filter() 65 | self.handle() 66 | 67 | def read_stdin(self): 68 | ''' 69 | Read data piped from stdin. 70 | ''' 71 | try: 72 | return sys.stdin.read() 73 | except Exception: 74 | raise ValueError('Nothing read from stdin') 75 | 76 | def read_event(self, check_result): 77 | ''' 78 | Convert the piped check result (json) into a global 'event' dict 79 | ''' 80 | try: 81 | event = json.loads(check_result) 82 | event['occurrences'] = event.get('occurrences', 1) 83 | event['check'] = event.get('check', {}) 84 | event['client'] = event.get('client', {}) 85 | return event 86 | except Exception: 87 | raise ValueError('error reading event: ' + check_result) 88 | 89 | def handle(self): 90 | ''' 91 | Method that should be overwritten to provide handler logic. 92 | ''' 93 | print("ignoring event -- no handler defined.") 94 | 95 | def filter(self): 96 | ''' 97 | Filters exit the proccess if the event should not be handled. 98 | Filtering events is deprecated and will be removed in a future release. 99 | ''' 100 | 101 | if self.deprecated_filtering_enabled(): 102 | print('warning: event filtering in sensu-plugin is deprecated,' + 103 | 'see http://bit.ly/sensu-plugin') 104 | self.filter_disabled() 105 | self.filter_silenced() 106 | self.filter_dependencies() 107 | 108 | if self.deprecated_occurrence_filtering(): 109 | print('warning: occurrence filtering in sensu-plugin is' + 110 | 'deprecated, see http://bit.ly/sensu-plugin') 111 | self.filter_repeated() 112 | 113 | def deprecated_filtering_enabled(self): 114 | ''' 115 | Evaluates whether the event should be processed by any of the 116 | filter methods in this library. Defaults to true, 117 | i.e. deprecated filters are run by default. 118 | 119 | returns bool 120 | ''' 121 | return self.event['check'].get('enable_deprecated_filtering', False) 122 | 123 | def deprecated_occurrence_filtering(self): 124 | ''' 125 | Evaluates whether the event should be processed by the 126 | filter_repeated method. Defaults to true, i.e. filter_repeated 127 | will filter events by default. 128 | 129 | returns bool 130 | ''' 131 | 132 | return self.event['check'].get( 133 | 'enable_deprecated_occurrence_filtering', False) 134 | 135 | def bail(self, msg): 136 | ''' 137 | Gracefully terminate with message 138 | ''' 139 | client_name = self.event['client'].get('name', 'error:no-client-name') 140 | check_name = self.event['check'].get('name', 'error:no-check-name') 141 | print('{}: {}/{}'.format(msg, client_name, check_name)) 142 | sys.exit(0) 143 | 144 | def get_api_settings(self): 145 | ''' 146 | Return a dict of API settings derived first from ENV['SENSU_API_URL'] 147 | if set, then Sensu config `api` scope if configured, and finally 148 | falling back to to ipv4 localhost address on default API port. 149 | 150 | return dict 151 | ''' 152 | 153 | sensu_api_url = os.environ.get('SENSU_API_URL') 154 | if sensu_api_url: 155 | uri = urlparse(sensu_api_url) 156 | api_settings = { 157 | 'host': '{0}://{1}'.format(uri.scheme, uri.hostname), 158 | 'port': uri.port, 159 | 'user': uri.username, 160 | 'password': uri.password 161 | } 162 | else: 163 | api_settings = self.settings.get('api', {}) 164 | api_settings['host'] = api_settings.get( 165 | 'host', '127.0.0.1') 166 | api_settings['port'] = api_settings.get( 167 | 'port', 4567) 168 | 169 | return api_settings 170 | 171 | # API requests 172 | def api_request(self, method, path): 173 | ''' 174 | Query Sensu api for information. 175 | ''' 176 | if not hasattr(self, 'api_settings'): 177 | ValueError('api.json settings not found') 178 | 179 | if method.lower() == 'get': 180 | _request = requests.get 181 | elif method.lower() == 'post': 182 | _request = requests.post 183 | 184 | domain = self.api_settings['host'] 185 | uri = '{}:{}/{}'.format(domain, self.api_settings['port'], path) 186 | if self.api_settings.get('user') and self.api_settings.get('password'): 187 | auth = (self.api_settings['user'], self.api_settings['password']) 188 | else: 189 | auth = () 190 | req = _request(uri, auth=auth) 191 | return req 192 | 193 | def stash_exists(self, path): 194 | ''' 195 | Query Sensu API for stash data. 196 | ''' 197 | return self.api_request('get', '/stash' + path).status_code == 200 198 | 199 | def event_exists(self, client, check): 200 | ''' 201 | Query Sensu API for event. 202 | ''' 203 | return self.api_request( 204 | 'get', 205 | 'events/{}/{}'.format(client, check) 206 | ).status_code == 200 207 | 208 | # Filters 209 | def filter_disabled(self): 210 | ''' 211 | Determine whether a check is disabled and shouldn't handle. 212 | ''' 213 | if self.event['check']['alert'] is False: 214 | self.bail('alert disabled') 215 | 216 | def filter_silenced(self): 217 | ''' 218 | Determine whether a check is silenced and shouldn't handle. 219 | ''' 220 | stashes = [ 221 | ('client', '/silence/{}'.format(self.event['client']['name'])), 222 | ('check', '/silence/{}/{}'.format( 223 | self.event['client']['name'], 224 | self.event['check']['name'])), 225 | ('check', '/silence/all/{}'.format(self.event['check']['name'])) 226 | ] 227 | for scope, path in stashes: 228 | if self.stash_exists(path): 229 | self.bail(scope + ' alerts silenced') 230 | 231 | def filter_dependencies(self): 232 | ''' 233 | Determine whether a check has dependencies. 234 | ''' 235 | dependencies = self.event['check'].get('dependencies', None) 236 | if dependencies is None or not isinstance(dependencies, list): 237 | return 238 | for dependency in self.event['check']['dependencies']: 239 | if not str(dependency): 240 | continue 241 | dependency_split = tuple(dependency.split('/')) 242 | # If there's a dependency on a check from another client, then use 243 | # that client name, otherwise assume same client. 244 | if len(dependency_split) == 2: 245 | client, check = dependency_split 246 | else: 247 | client = self.event['client']['name'] 248 | check = dependency_split[0] 249 | if self.event_exists(client, check): 250 | self.bail('check dependency event exists') 251 | 252 | def filter_repeated(self): 253 | ''' 254 | Determine whether a check is repeating. 255 | ''' 256 | defaults = { 257 | 'occurrences': 1, 258 | 'interval': 30, 259 | 'refresh': 1800 260 | } 261 | 262 | # Override defaults with anything defined in the settings 263 | if isinstance(self.settings['sensu_plugin'], dict): 264 | defaults.update(self.settings['sensu_plugin']) 265 | 266 | occurrences = int(self.event['check'].get( 267 | 'occurrences', defaults['occurrences'])) 268 | interval = int(self.event['check'].get( 269 | 'interval', defaults['interval'])) 270 | refresh = int(self.event['check'].get( 271 | 'refresh', defaults['refresh'])) 272 | 273 | if self.event['occurrences'] < occurrences: 274 | self.bail('not enough occurrences') 275 | 276 | if (self.event['occurrences'] > occurrences and 277 | self.event['action'] == 'create'): 278 | return 279 | 280 | number = int(refresh / interval) 281 | if (number == 0 or 282 | (self.event['occurrences'] - occurrences) % number == 0): 283 | return 284 | 285 | self.bail('only handling every ' + str(number) + ' occurrences') 286 | --------------------------------------------------------------------------------