From e66fc872653efbed60114cea88d35d5a163812cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20L=C3=B8nne?= Date: Thu, 20 Sep 2012 00:07:50 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 + LICENSE | 24 ++++ README.md | 45 ++++++ doctor/__init__.py | 2 + doctor/management/__init__.py | 0 doctor/management/commands/__init__.py | 0 doctor/management/commands/test_email.py | 28 ++++ doctor/management/commands/test_storage.py | 45 ++++++ doctor/models.py | 0 doctor/templates/doctor/base.html | 1 + doctor/templates/doctor/index.html | 57 ++++++++ doctor/templates/doctor/technical_info.html | 47 +++++++ doctor/tests.py | 94 +++++++++++++ doctor/urls.py | 11 ++ doctor/views.py | 148 ++++++++++++++++++++ setup.py | 32 +++++ 16 files changed, 537 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doctor/__init__.py create mode 100644 doctor/management/__init__.py create mode 100644 doctor/management/commands/__init__.py create mode 100644 doctor/management/commands/test_email.py create mode 100644 doctor/management/commands/test_storage.py create mode 100644 doctor/models.py create mode 100644 doctor/templates/doctor/base.html create mode 100644 doctor/templates/doctor/index.html create mode 100644 doctor/templates/doctor/technical_info.html create mode 100644 doctor/tests.py create mode 100644 doctor/urls.py create mode 100644 doctor/views.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3230c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.egg-info/ +*.egg/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a89d543 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2012, Funkbit AS. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Dokus service nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY FUNKBIT AS ''AS IS'' +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL FUNKBIT AS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cb4305 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# django-doctor + +django-doctor is a Django app for checking the operational status of a Django +installation. It includes checking that caching and storage is correctly +set up, that email is working, etc. + +This is an early draft, so use it at your own risk. + + +## Installation + +Install `django-doctor` (available on PyPi): + + pip install django-doctor + +Add it to `INSTALLED_APPS` in your `settings.py`: + + INSTALLED_APPS += ['doctor'] + +And add it to your root urls.py file: + + url(r'^doctor/', include('doctor.urls')), + + +## Settings + +These are the available configurable settings, along with their default values: + + + + + + + + + + + + +
NameDefaultDescription
DOCTOR_BASE_TEMPLATE'base.html'The template all the doctor templates should inherit from
+ +## Tests + +Run unit tests by running python setup.py test + diff --git a/doctor/__init__.py b/doctor/__init__.py new file mode 100644 index 0000000..6140ac8 --- /dev/null +++ b/doctor/__init__.py @@ -0,0 +1,2 @@ +version_info = (0, 1, 0) +__version__ = '.'.join(map(str, version_info)) diff --git a/doctor/management/__init__.py b/doctor/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doctor/management/commands/__init__.py b/doctor/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doctor/management/commands/test_email.py b/doctor/management/commands/test_email.py new file mode 100644 index 0000000..9fc9321 --- /dev/null +++ b/doctor/management/commands/test_email.py @@ -0,0 +1,28 @@ +from django.contrib.sites.models import Site +from django.core.mail import mail_admins +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """ + Test the sending of email with the mail_admins command. + """ + + help = 'Test sending of email.' + + def handle(self, *args, **options): + + verbosity = int(options.get('verbosity', 1)) + + message = 'This is a test mail from %(site_name)s. If you see this, mail is working :)' % { + 'site_name': Site.objects.get_current().name, + } + + try: + mail_admins('Test mail', message, fail_silently=False) + + if verbosity > 0: + self.stdout.write('Mail successfully sent to admins.\n') + + except Exception as exc: + self.stderr.write('Sending test mail failed! Exception was: %s\n' % str(exc)) diff --git a/doctor/management/commands/test_storage.py b/doctor/management/commands/test_storage.py new file mode 100644 index 0000000..40364f6 --- /dev/null +++ b/doctor/management/commands/test_storage.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """ + Test various file storage operations, to ensure that integrations (with ie. + Amazon S3) is working properly. + """ + + help = 'Test file storage operations.' + + def handle(self, *args, **options): + + verbosity = int(options.get('verbosity', 1)) + filename = 'storage_test' + + if verbosity > 0: + self.stdout.write('Storage used: %s\n' % settings.DEFAULT_FILE_STORAGE) + + # Create a file + default_storage.save(filename, ContentFile('We are testing, 1 2 three.')) + + # Check for existence + file_exists = default_storage.exists(filename) + + if verbosity > 0: + self.stdout.write('Does newly created file exist? %s\n' % file_exists) + + # Read back the file + f = default_storage.open(filename, 'r') + file_contents = f.read() + f.close() + + if verbosity > 0: + self.stdout.write('Contents: "%s"\n' % file_contents) + + # Delete the file + default_storage.delete(filename) + file_exists = default_storage.exists(filename) + + if verbosity > 0: + self.stdout.write('Does file exist after deletion? %s\n' % file_exists) diff --git a/doctor/models.py b/doctor/models.py new file mode 100644 index 0000000..e69de29 diff --git a/doctor/templates/doctor/base.html b/doctor/templates/doctor/base.html new file mode 100644 index 0000000..8624e3f --- /dev/null +++ b/doctor/templates/doctor/base.html @@ -0,0 +1 @@ +{% extends base_template %} diff --git a/doctor/templates/doctor/index.html b/doctor/templates/doctor/index.html new file mode 100644 index 0000000..c48551c --- /dev/null +++ b/doctor/templates/doctor/index.html @@ -0,0 +1,57 @@ +{% extends "doctor/base.html" %} +{% load i18n %} + +{% block title %}{% trans 'Health checks' %} — {{ block.super }}{% endblock %} + +{% block content %} +

+ {% trans 'Health checks' %} +

+ +

+ {% trans 'Cache' %} +

+ + + + {% for cache_name, info in cache.iteritems %} + + + + + {% endfor %} + +
+

+ {{ cache_name }} +

+ + {% if info.is_working %} + {% trans 'Status:' %} {% trans 'OK' %}
+ {{ info.message }} + {% else %} +
+

{% trans 'Error' %}

+ {{ info.message }} +
+ {% endif %} +
+ + + + + + + + {% for key, val in info.settings.iteritems %} + + + + + {% endfor %} + +
{% trans 'Setting' %}{% trans 'Value' %}
{{ key }}{{ val }}
+ +
+ +{% endblock %} diff --git a/doctor/templates/doctor/technical_info.html b/doctor/templates/doctor/technical_info.html new file mode 100644 index 0000000..03fd67f --- /dev/null +++ b/doctor/templates/doctor/technical_info.html @@ -0,0 +1,47 @@ +{% extends "doctor/base.html" %} +{% load i18n %} + +{% block title %}{% trans 'Technical information' %} — {{ block.super }}{% endblock %} + +{% block content %} +

+ {% trans 'Technical information' %} +

+ + +

+ {% trans 'Module versions' %} +

+ + + + + + + + + + {% for package, version in versions.iteritems %} + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Version' %}
{{ package }}{{ version }}
+ + +

+ {% trans 'Environment' %} +

+ +
{% for key,val in environ.items %}{{ key }}: {{ val }}
{% endfor %}
+ + +

+ {% trans 'Paths' %} +

+ +
{% for path in paths %}{{ path }}
{% endfor %}
+ +{% endblock %} diff --git a/doctor/tests.py b/doctor/tests.py new file mode 100644 index 0000000..0e623b5 --- /dev/null +++ b/doctor/tests.py @@ -0,0 +1,94 @@ +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.test import TestCase + + +class DoctorAuthorizationTests(TestCase): + + def setUp(self): + + # Create active user to test with + user = User(username='example_user', is_active=True) + user.set_password('1234') + user.save() + + self.user = user + + def testAnonymous(self): + + # Available for all users (for now) + response = self.client.get(reverse('doctor-health-check')) + self.assertEquals(response.status_code, 200) + self.assertEquals(response['Content-Type'], 'text/plain') + + # Check non-accessable pages + response = self.client.get(reverse('doctor-index')) + self.assertEquals(response.status_code, 404) + + response = self.client.get(reverse('doctor-technical')) + self.assertEquals(response.status_code, 404) + + response = self.client.get(reverse('doctor-server-error')) + self.assertEquals(response.status_code, 404) + + def testNonPrivileged(self): + + # Sign in user + self.client.login(username=self.user.username, password='1234') + + # Available for all users (for now) + response = self.client.get(reverse('doctor-health-check')) + self.assertEquals(response.status_code, 200) + self.assertEquals(response['Content-Type'], 'text/plain') + + # Check non-accessable pages + response = self.client.get(reverse('doctor-index')) + self.assertEquals(response.status_code, 404) + + response = self.client.get(reverse('doctor-technical')) + self.assertEquals(response.status_code, 404) + + response = self.client.get(reverse('doctor-server-error')) + self.assertEquals(response.status_code, 404) + + +class DoctorViewTests(TestCase): + + def setUp(self): + + # Create super user to test with + user = User(username='example_user', is_active=True, is_superuser=True) + user.set_password('1234') + user.save() + + self.user = user + + def testPrivileged(self): + + # Sign in user + self.client.login(username=self.user.username, password='1234') + + # Check pages + response = self.client.get(reverse('doctor-index')) + self.assertEquals(response.status_code, 200) + self.assertTemplateUsed(response, 'doctor/index.html') + + response = self.client.get(reverse('doctor-health-check')) + self.assertEquals(response.status_code, 200) + self.assertEquals(response['Content-Type'], 'text/plain') + + response = self.client.get(reverse('doctor-technical')) + self.assertEquals(response.status_code, 200) + self.assertTemplateUsed(response, 'doctor/technical_info.html') + + def testForceServerError(self): + + # Make superuser + self.user.is_superuser = True + self.user.save() + + # Sign in user + self.client.login(username=self.user.username, password='1234') + + with self.assertRaises(Exception): + response = self.client.get(reverse('doctor-server-error')) diff --git a/doctor/urls.py b/doctor/urls.py new file mode 100644 index 0000000..8e5d487 --- /dev/null +++ b/doctor/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import patterns, include, url + +urlpatterns = patterns('doctor.views', + + url(r'^health-check/$', 'health_check', name='doctor-health-check'), + url(r'^technical/$', 'technical_info', name='doctor-technical'), + url(r'^server-error/$', 'force_server_error', name='doctor-server-error'), + + url(r'^$', 'index', name='doctor-index'), + +) diff --git a/doctor/views.py b/doctor/views.py new file mode 100644 index 0000000..d23c055 --- /dev/null +++ b/doctor/views.py @@ -0,0 +1,148 @@ +import datetime +import os +import socket +import sys + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import get_cache +from django.http import HttpResponse, Http404 +from django.shortcuts import render +from django.utils.datastructures import SortedDict +from django.utils.importlib import import_module +from django.views.debug import cleanse_setting + +# Fetch the socket name +socket_name = socket.gethostname() + +# Base template extended by all templates +BASE_TEMPLATE = getattr(settings, 'DOCTOR_BASE_TEMPLATE', 'base.html') + +def index(request): + """ + Various health checks, displayed as HTML. + """ + + # Reject non-superusers + if not request.user.is_superuser: + raise Http404('Superusers only.') + + caches_info = {} + + # Cache check + for cache_name in settings.CACHES.keys(): + + is_cache_working = True + + # Try to get the cache backend + try: + cache = get_cache(cache_name) + except Exception as ex: + is_cache_working = False + cache_message = str(ex) + + # Check the cache backend + if is_cache_working: + CACHE_KEY = 'doctor-cache-check-%s' % cache_name + cache_message = cache.get(CACHE_KEY, None) + + # If cache is empty, update cache + if cache_message is None: + + # Set timestamped message in cache + cache_message = 'From cache, set at %s' % datetime.datetime.now() + cache.set(CACHE_KEY, cache_message, 10) + + # Get again, to see if the message is persisted in cache + cache_message = cache.get(CACHE_KEY, 'Data not persisted in cache.') + + # Create dictionary with status info + caches_info[cache_name] = { + 'is_working': is_cache_working, + 'message': cache_message, + 'settings': settings.CACHES[cache_name], + } + + return render(request, 'doctor/index.html', { + 'base_template': BASE_TEMPLATE, + 'cache': caches_info, + }) + +def health_check(request): + """ + Basic health check view, returns plain text response with 200 OK response. + Useful for external monitor systems. + + TODO: Should we limit this to some extent? (maybe INTERNAL_IPS?) + """ + + response = HttpResponse(content_type='text/plain') + response['Cache-Control'] = 'no-cache' + + # Generate message and hit the database + msg = '%(domain)s running on %(socket_name)s is OK at %(datetime)s' % { + 'domain': Site.objects.get_current().domain, + 'socket_name': socket_name, + 'datetime': datetime.datetime.now() + } + response.write(msg) + + return response + +def technical_info(request): + """ + Version numbers for applications in use. + Borrowed from the django-debug-toolbar. + """ + + # Reject non-superusers + if not request.user.is_superuser: + raise Http404('Superusers only.') + + # Module version information + versions = [('Python', '%d.%d.%d' % sys.version_info[:3])] + + for app in list(settings.INSTALLED_APPS) + ['django']: + name = app.split('.')[-1].replace('_', ' ').capitalize() + app = import_module(app) + if hasattr(app, 'get_version'): + get_version = app.get_version + if callable(get_version): + version = get_version() + else: + version = get_version + elif hasattr(app, 'VERSION'): + version = app.VERSION + elif hasattr(app, '__version__'): + version = app.__version__ + else: + continue + if isinstance(version, (list, tuple)): + version = '.'.join(str(o) for o in version) + versions.append((name, version)) + versions = sorted(versions, key=lambda version: version[0]) + + # Return environment variables with sensitive content cleansed + environ = {} + for key, val in os.environ.iteritems(): + environ[key] = cleanse_setting(key, val) + + return render(request, 'doctor/technical_info.html', { + 'base_template': BASE_TEMPLATE, + 'versions': SortedDict(versions), + 'environ': environ, + 'paths': sys.path, + }) + +def force_server_error(request): + """ + Raises an exception. Useful for testing Sentry, error reporting mails. + """ + + # Reject non-superusers + if not request.user.is_superuser: + raise Http404('Superusers only.') + + raise Exception('This unhandled exception is here by design.') + + return HttpResponse('This should never show up.', content_type='text/plain') diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2dae68f --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +import os +import sys + +from doctor import __version__ +from setuptools import setup + +# Publish to Pypi +if sys.argv[-1] == 'publish': + os.system('python setup.py sdist upload') + sys.exit() + +setup(name='django-doctor', + version=__version__, + description='Django health check and test-that-it-works application.', + long_description=open('README.md').read(), + author='Funkbit AS', + author_email='post@funkbit.no', + url='https://github.com/funkbit/django-doctor', + packages=['doctor'], + tests_require=['django>=1.1,<1.4'], + license='BSD', + classifiers = ( + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + ) +)