├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── bin └── pagerunit ├── example.py ├── man ├── man1 │ ├── pagerunit.1 │ └── pagerunit.1.ronn ├── man5 │ ├── pagerunit.5 │ └── pagerunit.5.ronn └── man7 │ ├── pagerunit.7 │ └── pagerunit.7.ronn ├── pagerunit ├── __init__.py ├── decorators.py └── smtp.py ├── pydir.py ├── setup.py.m4 └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.deb 2 | *.egg 3 | *.egg-info 4 | *.html 5 | *.pyc 6 | *.pyo 7 | *.swp 8 | *.tar 9 | *.tar.gz 10 | .coverage 11 | NOTES 12 | build 13 | control 14 | dist 15 | setup.py 16 | var 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2011 Richard Crowley. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY RICHARD CROWLEY ``AS IS'' AND ANY EXPRESS 16 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL RICHARD CROWLEY OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 25 | THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | The views and conclusions contained in the software and documentation 28 | are those of the authors and should not be interpreted as representing 29 | official policies, either expressed or implied, of Richard Crowley. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=0.0.3 2 | BUILD=2 3 | 4 | PYTHON=$(shell which python2.7 || which python27 || which python2.6 || which python26 || which python) 5 | PYTHON_VERSION=$(shell $(PYTHON) -c "from distutils.sysconfig import get_python_version; print(get_python_version())") 6 | 7 | prefix=/usr/local 8 | bindir=$(prefix)/bin 9 | libdir=$(prefix)/lib 10 | pydir=$(shell $(PYTHON) pydir.py $(libdir)) 11 | mandir=$(prefix)/share/man 12 | 13 | all: 14 | 15 | clean: 16 | rm -rf \ 17 | *.deb \ 18 | setup.py build dist *.egg *.egg-info \ 19 | man/man*/*.html 20 | find . -name \*.pyc -delete 21 | 22 | test: 23 | nosetests --with-coverage --cover-package=pagerunit 24 | 25 | install: install-bin install-lib install-man 26 | 27 | install-bin: 28 | install -d $(DESTDIR)$(bindir) 29 | find bin -type f -printf %P\\0 | xargs -0r -I__ install bin/__ $(DESTDIR)$(bindir)/__ 30 | 31 | install-lib: 32 | find pagerunit -type d -printf %P\\0 | xargs -0r -I__ install -d $(DESTDIR)$(pydir)/pagerunit/__ 33 | find pagerunit -type f -name \*.py -printf %P\\0 | xargs -0r -I__ install -m644 pagerunit/__ $(DESTDIR)$(pydir)/pagerunit/__ 34 | PYTHONPATH=$(DESTDIR)$(pydir) $(PYTHON) -mcompileall $(DESTDIR)$(pydir)/pagerunit 35 | 36 | install-man: 37 | find man -type d -printf %P\\0 | xargs -0r -I__ install -d $(DESTDIR)$(mandir)/__ 38 | find man -type f -name \*.[12345678] -printf %P\\0 | xargs -0r -I__ install -m644 man/__ $(DESTDIR)$(mandir)/__ 39 | find man -type f -name \*.[12345678] -printf %P\\0 | xargs -0r -I__ gzip $(DESTDIR)$(mandir)/__ 40 | 41 | uninstall: uninstall-bin uninstall-lib uninstall-man 42 | 43 | uninstall-bin: 44 | find bin -type f -printf %P\\0 | xargs -0r -I__ rm -f $(DESTDIR)$(bindir)/__ 45 | rmdir -p --ignore-fail-on-non-empty $(DESTDIR)$(bindir) || true 46 | 47 | uninstall-lib: 48 | find pagerunit -type f -name \*.py -printf %P\\0 | xargs -0r -I__ rm -f $(DESTDIR)$(pydir)/pagerunit/__ $(DESTDIR)$(pydir)/pagerunit/__c 49 | find pagerunit -depth -mindepth 1 -type d -printf %P\\0 | xargs -0r -I__ rmdir $(DESTDIR)$(pydir)/pagerunit/__ || true 50 | rmdir -p --ignore-fail-on-non-empty $(DESTDIR)$(pydir)/pagerunit || true 51 | 52 | uninstall-man: 53 | find man -type f -name \*.[12345678] -printf %P\\0 | xargs -0r -I__ rm -f $(DESTDIR)$(mandir)/__.gz 54 | find man -depth -mindepth 1 -type d -printf %P\\0 | xargs -0r -I__ rmdir $(DESTDIR)$(mandir)/__ || true 55 | rmdir -p --ignore-fail-on-non-empty $(DESTDIR)$(mandir) || true 56 | 57 | build: build-deb build-pypi 58 | 59 | build-deb: 60 | make install prefix=/usr DESTDIR=debian 61 | fpm -s dir -t deb -C debian \ 62 | -n pagerunit -v $(VERSION)-$(BUILD)py$(PYTHON_VERSION) -a all \ 63 | -d python$(PYTHON_VERSION) \ 64 | -m "Richard Crowley " \ 65 | --url "https://github.com/rcrowley/pagerunit" \ 66 | --description "A simple Nagios alternative made to look like unit tests." 67 | make uninstall prefix=/usr DESTDIR=debian 68 | 69 | build-pypi: 70 | m4 -D__VERSION__=$(VERSION) setup.py.m4 >setup.py 71 | $(PYTHON) setup.py bdist_egg 72 | 73 | deploy: deploy-deb deploy-pypi 74 | 75 | deploy-deb: 76 | scp -i ~/production.pem pagerunit_$(VERSION)-$(BUILD)py$(PYTHON_VERSION)_all.deb ubuntu@packages.devstructure.com: 77 | make deploy-deb-py$(PYTHON_VERSION) 78 | ssh -i ~/production.pem -t ubuntu@packages.devstructure.com "rm pagerunit_$(VERSION)-$(BUILD)py$(PYTHON_VERSION)_all.deb" 79 | 80 | deploy-deb-py2.6: 81 | ssh -i ~/production.pem -t ubuntu@packages.devstructure.com "sudo freight add pagerunit_$(VERSION)-$(BUILD)py$(PYTHON_VERSION)_all.deb apt/lenny apt/squeeze apt/lucid apt/maverick" 82 | ssh -i ~/production.pem -t ubuntu@packages.devstructure.com "sudo freight cache apt/lenny apt/squeeze apt/lucid apt/maverick" 83 | 84 | deploy-deb-py2.7: 85 | ssh -i ~/production.pem -t ubuntu@packages.devstructure.com "sudo freight add pagerunit_$(VERSION)-$(BUILD)py$(PYTHON_VERSION)_all.deb apt/natty" 86 | ssh -i ~/production.pem -t ubuntu@packages.devstructure.com "sudo freight cache apt/natty" 87 | 88 | deploy-pypi: 89 | $(PYTHON) setup.py sdist upload 90 | 91 | man: 92 | find man -name \*.ronn | xargs -n1 ronn --manual=PagerUnit --style=toc 93 | 94 | gh-pages: man 95 | mkdir -p gh-pages 96 | find man -name \*.html | xargs -I__ mv __ gh-pages/ 97 | git checkout -q gh-pages 98 | cp -R gh-pages/* ./ 99 | rm -rf gh-pages 100 | git add . 101 | git commit -m "Rebuilt manual." 102 | git push origin gh-pages 103 | git checkout -q master 104 | 105 | .PHONY: all clean install install-bin install-lib install-man uninstall uninstall-bin uninstall-lib uninstall-man build build-deb build-pypi deploy deploy-deb deploy-deb-py2.6 deploy-deb-py2.7 deploy-pypi man gh-pages 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PagerUnit 2 | 3 | A simple Nagios alternative made to look like unit tests. 4 | 5 | This is probably a bad idea but I wanted to get something on paper (as it were) so I could get back to [real](https://github.com/devstructure/blueprint) [work](https://github.com/devstructure/blueprint-io). 6 | 7 | ## Usage 8 | 9 | Configure PagerUnit so it can send email in `/etc/pagerunit.cfg` or `~/.pagerunit.cfg`: 10 | 11 | [mail] 12 | address = recipient@example.com 13 | 14 | [smtp] 15 | password = password 16 | port = 587 17 | server = smtp.gmail.com 18 | username = sender@gmail.com 19 | 20 | Define some tests a la [Nose](http://somethingaboutorange.com/mrl/projects/nose/1.0.0/): 21 | 22 | def foo(): 23 | """ 24 | Docstring for foo. 25 | """ 26 | assert False, 'Assertion for foo.' 27 | 28 | Run them every 10 seconds: 29 | 30 | pagerunit --loop example.py 31 | 32 | ## Installation 33 | 34 | Prerequisites: 35 | 36 | * Python >= 2.6 37 | 38 | ### From source 39 | 40 | git clone git://github.com/rcrowley/pagerunit.git 41 | cd pagerunit && make && sudo make install 42 | 43 | ### From DevStructure's Debian archive 44 | 45 |
echo "deb http://packages.devstructure.com release main" | sudo tee /etc/apt/sources.list.d/devstructure.list
46 | sudo wget -O /etc/apt/trusted.gpg.d/devstructure.gpg http://packages.devstructure.com/keyring.gpg
47 | sudo apt-get update
48 | sudo apt-get -y install pagerunit
49 | 50 | Replace release with "`lenny`", "`squeeze`", "`lucid`", "`maverick`", or "`natty`" as your situation requires. 51 | 52 | ### From PyPI 53 | 54 | pip install pagerunit 55 | -------------------------------------------------------------------------------- /bin/pagerunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Run the given PagerUnit tests, possibly in an infinite loop. 5 | """ 6 | 7 | import logging 8 | import optparse 9 | import sys 10 | import time 11 | 12 | logging.basicConfig(format='pagerunit: %(message)s', level=logging.INFO) 13 | 14 | import pagerunit 15 | 16 | parser = optparse.OptionParser('Usage: %prog [-l] [-d ] [-q] ') 17 | parser.add_option('-l', '--loop', 18 | dest='loop', 19 | default=False, 20 | action='store_true', 21 | help='loop forever') 22 | parser.add_option('-q', '--quiet', 23 | dest='quiet', 24 | default=False, 25 | action='store_true', 26 | help='operate quietly') 27 | options, args = parser.parse_args() 28 | 29 | if options.quiet: 30 | logging.root.setLevel(logging.CRITICAL) 31 | 32 | if 0 == len(args): 33 | parser.print_usage() 34 | sys.exit(1) 35 | 36 | p = pagerunit.PagerUnit(args) 37 | if options.loop: 38 | while True: 39 | p() 40 | time.sleep(10) 41 | else: 42 | p() 43 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trivial examples for Pager Unit. 3 | """ 4 | 5 | from pagerunit.decorators import * 6 | 7 | 8 | def foo(): 9 | """ 10 | Docstring for foo. 11 | """ 12 | assert True, 'Assertion for foo.' 13 | 14 | 15 | def bar(): 16 | """ 17 | Docstring for bar. 18 | """ 19 | assert False, 'Assertion for bar.' 20 | 21 | @disabled 22 | def baz(): 23 | pass 24 | 25 | @silent 26 | def quuz(): 27 | pass 28 | -------------------------------------------------------------------------------- /man/man1/pagerunit.1: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "PAGERUNIT" "1" "July 2011" "" "PagerUnit" 5 | . 6 | .SH "NAME" 7 | \fBpagerunit\fR \- a simple Nagios alternative made to look like unit tests 8 | . 9 | .SH "SYNOPSIS" 10 | . 11 | .SH "DESCRIPTION" 12 | . 13 | .SH "OPTIONS" 14 | . 15 | .SH "FILES" 16 | . 17 | .TP 18 | \fB/etc/pagerunit\.cfg\fR, \fB~/\.pagerunit\.cfg\fR 19 | PagerUnit\'s configuration file, where mail server information is stored\. See \fBpagerunit\fR(5)\. 20 | . 21 | .SH "THEME SONG" 22 | Beirut \- "Elephant Gun" 23 | . 24 | .SH "AUTHOR" 25 | Richard Crowley \fIr@rcrowley\.org\fR 26 | . 27 | .SH "SEE ALSO" 28 | \fBpagerunit\fR(5) documents the configuration file format\. \fBpagerunit\fR(7) documents the Python library\. 29 | -------------------------------------------------------------------------------- /man/man1/pagerunit.1.ronn: -------------------------------------------------------------------------------- 1 | pagerunit(1) -- a simple Nagios alternative made to look like unit tests 2 | ======================================================================== 3 | 4 | ## SYNOPSIS 5 | 6 | ## DESCRIPTION 7 | 8 | ## OPTIONS 9 | 10 | ## FILES 11 | 12 | * `/etc/pagerunit.cfg`, `~/.pagerunit.cfg`: 13 | PagerUnit's configuration file, where mail server information is stored. See `pagerunit`(5). 14 | 15 | ## THEME SONG 16 | 17 | Beirut - "Elephant Gun" 18 | 19 | ## AUTHOR 20 | 21 | Richard Crowley 22 | 23 | ## SEE ALSO 24 | 25 | `pagerunit`(5) documents the configuration file format. `pagerunit`(7) documents the Python library. 26 | -------------------------------------------------------------------------------- /man/man5/pagerunit.5: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "PAGERUNIT" "5" "July 2011" "" "PagerUnit" 5 | . 6 | .SH "NAME" 7 | \fBpagerunit\fR \- a simple Nagios alternative made to look like unit tests 8 | . 9 | .SH "SYNOPSIS" 10 | . 11 | .SH "DESCRIPTION" 12 | . 13 | .SH "OPTIONS" 14 | . 15 | .SH "THEME SONG" 16 | Beirut \- "Elephant Gun" 17 | . 18 | .SH "AUTHOR" 19 | Richard Crowley \fIr@rcrowley\.org\fR 20 | . 21 | .SH "SEE ALSO" 22 | \fBpagerunit\fR(1) documents the command\-line tool\. \fBpagerunit\fR(7) documents the Python library\. 23 | -------------------------------------------------------------------------------- /man/man5/pagerunit.5.ronn: -------------------------------------------------------------------------------- 1 | pagerunit(5) -- a simple Nagios alternative made to look like unit tests 2 | ======================================================================== 3 | 4 | ## SYNOPSIS 5 | 6 | ## DESCRIPTION 7 | 8 | ## OPTIONS 9 | 10 | ## THEME SONG 11 | 12 | Beirut - "Elephant Gun" 13 | 14 | ## AUTHOR 15 | 16 | Richard Crowley 17 | 18 | ## SEE ALSO 19 | 20 | `pagerunit`(1) documents the command-line tool. `pagerunit`(7) documents the Python library. 21 | -------------------------------------------------------------------------------- /man/man7/pagerunit.7: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "PAGERUNIT" "7" "July 2011" "" "PagerUnit" 5 | . 6 | .SH "NAME" 7 | \fBpagerunit\fR \- a simple Nagios alternative made to look like unit tests 8 | . 9 | .SH "SYNOPSIS" 10 | . 11 | .SH "DESCRIPTION" 12 | . 13 | .SH "OPTIONS" 14 | . 15 | .SH "FILES" 16 | . 17 | .TP 18 | \fB/etc/pagerunit\.cfg\fR, \fB~/\.pagerunit\.cfg\fR 19 | PagerUnit\'s configuration file, where mail server information is stored\. See \fBpagerunit\fR(5)\. 20 | . 21 | .SH "THEME SONG" 22 | Beirut \- "Elephant Gun" 23 | . 24 | .SH "AUTHOR" 25 | Richard Crowley \fIr@rcrowley\.org\fR 26 | . 27 | .SH "SEE ALSO" 28 | \fBpagerunit\fR(1) documents the command\-line tool\. \fBpagerunit\fR(5) documents the configuration file format\. 29 | -------------------------------------------------------------------------------- /man/man7/pagerunit.7.ronn: -------------------------------------------------------------------------------- 1 | pagerunit(7) -- a simple Nagios alternative made to look like unit tests 2 | ======================================================================== 3 | 4 | ## SYNOPSIS 5 | 6 | ## DESCRIPTION 7 | 8 | ## OPTIONS 9 | 10 | ## FILES 11 | 12 | * `/etc/pagerunit.cfg`, `~/.pagerunit.cfg`: 13 | PagerUnit's configuration file, where mail server information is stored. See `pagerunit`(5). 14 | 15 | ## THEME SONG 16 | 17 | Beirut - "Elephant Gun" 18 | 19 | ## AUTHOR 20 | 21 | Richard Crowley 22 | 23 | ## SEE ALSO 24 | 25 | `pagerunit`(1) documents the command-line tool. `pagerunit`(5) documents the configuration file format. 26 | -------------------------------------------------------------------------------- /pagerunit/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PagerUnit, a simple Nagios alternative made to look like unit tests. 3 | """ 4 | 5 | from ConfigParser import ConfigParser 6 | import errno 7 | import imp 8 | import inspect 9 | import logging 10 | import os 11 | import os.path 12 | import socket 13 | import sys 14 | import traceback 15 | import types 16 | 17 | import smtp 18 | 19 | # ConfigParser defaults are a bit limited so PagerUnit rolls its own. These 20 | # are placed before the config files are opened. 21 | DEFAULTS = {'mail': {'batch': False, 22 | 'batch_subject': '{problems} PROBLEMS, {recoveries} RECOVERIES on {fqdn}', 23 | 'heartbeat': False, 24 | 'problem_body': '{exc}\n\n\t{line}\n\n{doc}', 25 | 'problem_subject': 'PROBLEM {name} on {fqdn}', 26 | 'recovery_body': '{doc}', 27 | 'recovery_subject': 'RECOVERY {name} on {fqdn}'}, 28 | 'sms': {'batch': False, 29 | 'batch_body': 'PROBLEMS: {problems}; RECOVERIES: {recoveries} on {fqdn}', 30 | 'problem_body': 'PROBLEM {name} on {fqdn}', 31 | 'recovery_body': 'RECOVERY {name} on {fqdn}'}, 32 | 'smtp': {'port': 587, 33 | 'server': 'smtp.gmail.com'}, 34 | 'state': {'dirname': '/var/lib/pagerunit'}} 35 | 36 | def _strip(s): 37 | """ 38 | Strip whitespace from a multiline string. 39 | """ 40 | if s is None: 41 | return '' 42 | return ''.join([line.strip() + '\n' for line in s.strip().splitlines()]) 43 | 44 | class PagerUnit(object): 45 | """ 46 | A PagerUnit instance, which is initialized with a set of tests to run, 47 | possibly in an infinite loop. 48 | """ 49 | 50 | def __init__(self, pathnames): 51 | """ 52 | Initialize this PagerUnit. Save the list of test files and initialize 53 | the directory used to store runtime state. 54 | """ 55 | try: 56 | os.makedirs(self.cfg.get('state', 'dirname')) 57 | except OSError as e: 58 | if errno.EEXIST != e.errno: 59 | raise e 60 | self.pathnames = pathnames 61 | 62 | def __call__(self): 63 | """ 64 | Run all of the tests once and send batch messages. 65 | """ 66 | results = [] 67 | for pathname in self.pathnames: 68 | units = imp.load_source('units', pathname) 69 | for attr in (getattr(units, attrname) for attrname in dir(units)): 70 | if types.FunctionType != type(attr): 71 | continue 72 | if 'units' != attr.__module__: 73 | continue 74 | spec = inspect.getargspec(attr) 75 | if 0 < len(spec[0]) \ 76 | or spec[1] is not None \ 77 | or spec[2] is not None: 78 | continue 79 | result = self.unit(attr) 80 | if result: 81 | results.append(result) 82 | self.batch(results) 83 | return results 84 | 85 | @property 86 | def cfg(self): 87 | """ 88 | Lazy-load the PagerUnit configuration file(s). 89 | """ 90 | if not hasattr(self, '_cfg'): 91 | self._cfg = ConfigParser() 92 | for section, options in DEFAULTS.iteritems(): 93 | self._cfg.add_section(section) 94 | for option, value in options.iteritems(): 95 | self._cfg.set(section, option, str(value)) 96 | self._cfg.read(['/etc/pagerunit.cfg', 97 | os.path.expanduser('~/.pagerunit.cfg')]) 98 | return self._cfg 99 | 100 | @property 101 | def smtp(self): 102 | """ 103 | Lazy-create the SMTP gateway. 104 | """ 105 | if not hasattr(self, '_smtp'): 106 | self._smtp = smtp.SMTP(self.cfg.get('smtp', 'server'), 107 | self.cfg.getint('smtp', 'port'), 108 | self.cfg.get('smtp', 'username'), 109 | self.cfg.get('smtp', 'password')) 110 | return self._smtp 111 | 112 | def batch(self, results): 113 | """ 114 | Send batch messages for this list of results. 115 | """ 116 | self.batch_mail(results) 117 | self.batch_sms(results) 118 | 119 | def batch_mail(self, results): 120 | """ 121 | Send this list of results as a batch mail message. 122 | """ 123 | if not self.cfg.getboolean('mail', 'batch'): 124 | return None 125 | if not self.cfg.has_option('mail', 'address'): 126 | return None 127 | if not len(results) and not self.cfg.getboolean('mail', 'heartbeat'): 128 | return None 129 | 130 | # Create a batch MIMEJSON part and a MIMEText part for each 131 | # problem or recovery result. 132 | bodies = [smtp.MIMEJSON(results)] 133 | for r in results: 134 | if 'exc' in r: 135 | s, b = ('problem_subject', 'problem_body') 136 | else: 137 | s, b = ('recovery_subject', 'recovery_body') 138 | bodies.append(smtp.mime_text( 139 | self.cfg.get('mail', s) + '\n\n' + \ 140 | self.cfg.get('mail', b) + '\n\n', **r)) 141 | 142 | # Count the total number of problems and recoveries. 143 | problems = len([r for r in results if 'exc' in r]) 144 | recoveries = len([r for r in results if 'exc' not in r]) 145 | 146 | # Send the batch as a single multipart message. 147 | return self.smtp.send_multipart(self.cfg.get('mail', 'address'), 148 | self.cfg.get('mail', 'batch_subject'), 149 | *bodies, 150 | fqdn=socket.getfqdn(), 151 | problems=problems, 152 | recoveries=recoveries) 153 | 154 | def batch_sms(self, results): 155 | """ 156 | Send this list of results as a batch SMS message. 157 | """ 158 | if not self.cfg.getboolean('sms', 'batch'): 159 | return None 160 | if not self.cfg.has_option('sms', 'address'): 161 | return None 162 | if not len(results): 163 | return None 164 | 165 | # Split the results into problems and recoveries. 166 | problems = ', '.join([r['name'] for r in results 167 | if 'exc' in r]) or '(none)' 168 | recoveries = ', '.join([r['name'] for r in results 169 | if 'exc' not in r]) or '(none)' 170 | 171 | # Send the batch as a single message. 172 | return self.smtp.send(self.cfg.get('sms', 'address'), 173 | None, 174 | self.cfg.get('sms', 'batch_body'), 175 | fqdn=socket.getfqdn(), 176 | problems=problems, 177 | recoveries=recoveries) 178 | 179 | def problem(self, f, exc, tb): 180 | """ 181 | Add this problem to the runtime state and send a problem message. 182 | If the state file already exists, let the failure pass silently 183 | since it is a pre-existing condition. 184 | """ 185 | try: 186 | fd = os.open(os.path.join(self.cfg.get('state', 'dirname'), 187 | f.__name__), 188 | os.O_WRONLY | os.O_CREAT | os.O_EXCL, 189 | 0o644) 190 | os.close(fd) 191 | except OSError as e: 192 | if errno.EEXIST == e.errno: 193 | return 194 | elif errno.ENOSPC == e.errno: 195 | pass 196 | else: 197 | raise e 198 | logging.info('{0} has a problem'.format(f.__name__)) 199 | result = dict(name=f.__name__, 200 | fqdn=socket.getfqdn(), 201 | exc=str(exc) or '(no explanation)', 202 | line=traceback.extract_tb(tb)[-1][-1], 203 | doc=_strip(f.__doc__)) 204 | self.problem_mail(result) 205 | self.problem_sms(result) 206 | return result 207 | 208 | def problem_mail(self, result): 209 | """ 210 | Send this problem as a mail message. 211 | """ 212 | if self.cfg.getboolean('mail', 'batch'): 213 | return None 214 | if not self.cfg.has_option('mail', 'address'): 215 | return None 216 | return self.smtp.send_json(self.cfg.get('mail', 'address'), 217 | self.cfg.get('mail', 'problem_subject'), 218 | self.cfg.get('mail', 'problem_body'), 219 | **result) 220 | 221 | def problem_sms(self, result): 222 | """ 223 | Send this problem as an SMS message. 224 | """ 225 | if self.cfg.getboolean('sms', 'batch'): 226 | return None 227 | if not self.cfg.has_option('sms', 'address'): 228 | return None 229 | return self.smtp.send(self.cfg.get('sms', 'address'), 230 | None, 231 | self.cfg.get('sms', 'problem_body'), 232 | **result) 233 | 234 | def recovery(self, f): 235 | """ 236 | Remove this now-recovered problem from the runtime state and send 237 | a recovery message. 238 | """ 239 | try: 240 | os.unlink(os.path.join(self.cfg.get('state', 'dirname'), 241 | f.__name__)) 242 | except OSError as e: 243 | if errno.ENOENT == e.errno: 244 | return 245 | else: 246 | raise e 247 | logging.info('{0} recovered'.format(f.__name__)) 248 | result = dict(name=f.__name__, 249 | fqdn=socket.getfqdn(), 250 | doc=_strip(f.__doc__)) 251 | self.recovery_mail(result) 252 | self.recovery_sms(result) 253 | return result 254 | 255 | def recovery_mail(self, result): 256 | """ 257 | Send this recovery as a mail message. 258 | """ 259 | if self.cfg.getboolean('mail', 'batch'): 260 | return None 261 | if not self.cfg.has_option('mail', 'address'): 262 | return None 263 | return self.smtp.send_json(self.cfg.get('mail', 'address'), 264 | self.cfg.get('mail', 'recovery_subject'), 265 | self.cfg.get('mail', 'recovery_body'), 266 | **result) 267 | 268 | def recovery_sms(self, result): 269 | """ 270 | Send this recovery as an SMS message. 271 | """ 272 | if self.cfg.getboolean('sms', 'batch'): 273 | return None 274 | if not self.cfg.has_option('sms', 'address'): 275 | return None 276 | return self.smtp.send(self.cfg.get('sms', 'address'), 277 | None, 278 | self.cfg.get('sms', 'recovery_body'), 279 | **result) 280 | 281 | def unit(self, f): 282 | """ 283 | Run a single test and send problem/recovery messages as appropriate. 284 | """ 285 | try: 286 | f() 287 | return self.recovery(f) 288 | except AssertionError as e: 289 | return self.problem(f, e, sys.exc_info()[2]) 290 | except Exception as e: 291 | return self.problem(f, e, sys.exc_info()[2]) 292 | -------------------------------------------------------------------------------- /pagerunit/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Useful decorators for PagerUnit tests. 3 | """ 4 | 5 | def disabled(f): 6 | """ 7 | Mark the decorated function as disabled, which has the effect of skipping 8 | running it altogether. 9 | """ 10 | def decorated(*args, **kwargs): 11 | pass 12 | decorated.__name__ = f.__name__ 13 | decorated.__doc__ = f.__doc__ 14 | return decorated 15 | 16 | def silent(f): 17 | """ 18 | Mark the decorated function as silent, which has the effect of running 19 | it but ignoring `AssertionError`. 20 | """ 21 | def decorated(*args, **kwargs): 22 | try: 23 | f(*args, **kwargs) 24 | except AssertionError: 25 | pass 26 | decorated.__name__ = f.__name__ 27 | decorated.__doc__ = f.__doc__ 28 | return decorated 29 | -------------------------------------------------------------------------------- /pagerunit/smtp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send problem and recovery messages through the configured SMTP gateway. 3 | """ 4 | 5 | import email 6 | from email.mime.multipart import MIMEMultipart 7 | from email.mime.nonmultipart import MIMENonMultipart 8 | from email.mime.text import MIMEText 9 | import json 10 | import smtplib 11 | 12 | class MIMEJSON(MIMENonMultipart): 13 | """ 14 | A JSON-serialized object as an email. 15 | """ 16 | 17 | def __init__(self, *args, **kwargs): 18 | """ 19 | Create a MIME part, JSON-serializing the given object. The object 20 | may be given as the only positional argument or as keyword arguments. 21 | The object may be a list or a dict. If it is a list, the assumption 22 | is that this is a batch and the filename reflects that. 23 | """ 24 | MIMENonMultipart.__init__(self, 'application', 'json', charset='utf-8') 25 | try: 26 | obj = args[0] 27 | except IndexError: 28 | obj = kwargs 29 | try: 30 | name = obj['name'] 31 | except TypeError: 32 | name = 'batch' 33 | self.add_header('Content-Disposition', 34 | 'attachment', 35 | filename='{0}.json'.format(name)) 36 | self.set_payload(json.dumps(obj)) 37 | 38 | def mime_json(*args, **kwargs): 39 | return MIMEJSON(*args, **kwargs) 40 | 41 | def mime_text(body, **kwargs): 42 | return MIMEText(body.format(**kwargs)) 43 | 44 | class SMTP(object): 45 | """ 46 | An SMTP gateway instance that knows how to format PagerUnit messages. 47 | 48 | Based on . 49 | """ 50 | 51 | def __init__(self, server, port, username, password): 52 | self.smtp = smtplib.SMTP(server, port) 53 | self.username = username 54 | self.password = password 55 | self.smtp.ehlo(self.username) 56 | self.smtp.starttls() 57 | self.smtp.ehlo(self.username) 58 | self.smtp.login(self.username, self.password) 59 | 60 | def __del__(self): 61 | self.smtp.quit() 62 | 63 | def send(self, address, subject, body, **kwargs): 64 | """ 65 | Send a message through the configured SMTP gateway. 66 | """ 67 | addresses = [a.strip() for a in address.split(',')] 68 | m = mime_text(body, **kwargs) 69 | m['From'] = self.username 70 | m['Reply-To'] = addresses[0] 71 | m['To'] = address 72 | if subject is not None: 73 | m['Subject'] = subject.format(**kwargs) 74 | self.smtp.sendmail(self.username, addresses, m.as_string()) 75 | return m 76 | 77 | def send_json(self, address, subject, body, **kwargs): 78 | """ 79 | Send a MIME multipart message with two parts, one human-readable 80 | and the other the JSON-encoded raw data used to generated the 81 | human-readable part. 82 | """ 83 | return self.send_multipart(address, 84 | subject, 85 | mime_text(body, **kwargs), 86 | mime_json(**kwargs), 87 | **kwargs) 88 | 89 | def send_multipart(self, address, subject, *args, **kwargs): 90 | """ 91 | Send a MIME multipart message through the configured SMTP gateway. 92 | """ 93 | addresses = [a.strip() for a in address.split(',')] 94 | m = MIMEMultipart(_subparts=args) 95 | m['From'] = self.username 96 | m['Reply-To'] = addresses[0] 97 | m['To'] = address 98 | if subject is not None: 99 | m['Subject'] = subject.format(**kwargs) 100 | self.smtp.sendmail(self.username, addresses, m.as_string()) 101 | return m 102 | -------------------------------------------------------------------------------- /pydir.py: -------------------------------------------------------------------------------- 1 | from distutils.sysconfig import get_python_version, get_python_lib 2 | import os.path 3 | import sys 4 | for s in ('dist', 'site'): 5 | pydir = os.path.join(sys.argv[1], 6 | 'python%s' % get_python_version(), 7 | '%s-packages' % s) 8 | if pydir in sys.path: 9 | print(pydir) 10 | sys.exit(0) 11 | print(get_python_lib()) 12 | -------------------------------------------------------------------------------- /setup.py.m4: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name='pagerunit', 4 | version='__VERSION__', 5 | description='A simple Nagios alternative made to look like unit tests.', 6 | author='Richard Crowley', 7 | author_email='r@rcrowley.org', 8 | url='https://github.com/rcrowley/pagerunit', 9 | packages=find_packages(), 10 | scripts=['bin/pagerunit'], 11 | license='BSD', 12 | zip_safe=False) 13 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Isolated unit-ish tests for PagerUnit. 3 | """ 4 | 5 | import shutil 6 | import smtplib 7 | import socket 8 | import tempfile 9 | 10 | class Dummy(object): 11 | """ 12 | No-op version of the standard library's smtplib.SMTP class. 13 | """ 14 | 15 | def __init__(self, *args, **kwargs): 16 | pass 17 | 18 | def ehlo(self, *args, **kwargs): 19 | pass 20 | 21 | def login(self, *args, **kwargs): 22 | pass 23 | 24 | def quit(self): 25 | pass 26 | 27 | def sendmail(self, *args, **kwargs): 28 | pass 29 | 30 | def starttls(self, *args, **kwargs): 31 | pass 32 | 33 | smtplib.SMTP = Dummy 34 | 35 | import pagerunit 36 | from pagerunit import smtp 37 | 38 | 39 | p = pagerunit.PagerUnit(['example.py']) 40 | 41 | def setup(): 42 | p.cfg.set('mail', 'address', 'test@example.com') 43 | p.cfg.set('mail', 'batch', str(True)) 44 | p.cfg.set('sms', 'address', '5555555555@txt.att.net') 45 | p.cfg.set('sms', 'batch', str(True)) 46 | p.cfg.set('smtp', 'username', 'test@example.com') 47 | p.cfg.set('state', 'dirname', tempfile.mkdtemp()) 48 | 49 | def teardown(): 50 | shutil.rmtree(p.cfg.get('state', 'dirname')) 51 | 52 | 53 | def test_mime_json(): 54 | assert '''Content-Type: application/json; charset="utf-8" 55 | MIME-Version: 1.0 56 | Content-Disposition: attachment; filename="test.json" 57 | 58 | {"name": "test"}''' == smtp.mime_json(name='test').as_string() 59 | 60 | def test_mime_text(): 61 | assert '''Content-Type: text/plain; charset="us-ascii" 62 | MIME-Version: 1.0 63 | Content-Transfer-Encoding: 7bit 64 | 65 | test''' == smtp.mime_text('{test}', test='test').as_string() 66 | 67 | def test_strip(): 68 | assert '''foo 69 | bar 70 | baz 71 | ''' == pagerunit._strip(''' foo\t 72 | \tbar\r 73 | baz ''') 74 | 75 | 76 | def test_send(): 77 | m = p.smtp.send('test1@example.com,test2@example.com', 78 | '{name} subject', 79 | '{name} body', 80 | name='test') 81 | assert 'test@example.com' == m['From'] 82 | assert 'test1@example.com' == m['Reply-To'], m['Reply-To'] 83 | assert 'test1@example.com,test2@example.com' == m['To'] 84 | 85 | def test_send_multipart(): 86 | m = p.smtp.send_multipart('test1@example.com,test2@example.com', 87 | '{test} subject', 88 | smtp.mime_text('{test} body 1', test='test'), 89 | smtp.mime_text('{test} body 2', test='test'), 90 | test='test') 91 | assert 'test@example.com' == m['From'] 92 | assert 'test1@example.com' == m['Reply-To'], m['Reply-To'] 93 | assert 'test1@example.com,test2@example.com' == m['To'] 94 | assert 2 == len(m.get_payload()) 95 | 96 | 97 | def test_problem(): 98 | def test(): 99 | assert False, 'Test assertion.' 100 | result = p.unit(test) 101 | assert 'exc' in result 102 | 103 | def test_recovery(): 104 | def test(): 105 | assert False, 'Test assertion.' 106 | result = p.unit(test) 107 | def test(): 108 | assert True, 'Test assertion.' 109 | result = p.unit(test) 110 | assert 'exc' not in result 111 | 112 | def test_steady(): 113 | def test(): 114 | assert True, 'Test assertion.' 115 | result = p.unit(test) 116 | assert result is None 117 | 118 | 119 | def test_call(): 120 | teardown() 121 | setup() 122 | results = p() 123 | assert 1 == len(results), results 124 | return results 125 | 126 | def test_batch_mail(): 127 | m = p.batch_mail(test_call()) 128 | payload = m.get_payload() 129 | assert 2 == len(payload) 130 | assert ['application/json', 'text/plain'] == [part.get_content_type() 131 | for part in payload] 132 | assert '1 PROBLEMS, 0 RECOVERIES on {0}'.format(socket.getfqdn()) \ 133 | == m['Subject'] 134 | 135 | def test_batch_sms(): 136 | m = p.batch_sms(test_call()) 137 | assert 'text/plain' == m.get_content_type() 138 | assert '''Content-Type: text/plain; charset="us-ascii" 139 | MIME-Version: 1.0 140 | Content-Transfer-Encoding: 7bit 141 | From: test@example.com 142 | Reply-To: 5555555555@txt.att.net 143 | To: 5555555555@txt.att.net 144 | 145 | PROBLEMS: bar; RECOVERIES: (none) on vagrant.vagrantup.com''' == m.as_string() 146 | --------------------------------------------------------------------------------