├── .dockerignore ├── .github └── workflows │ ├── build.yml │ └── tests.yml ├── .gitignore ├── .python-version ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_alive ├── __init__.py ├── checks.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── healthcheck.py ├── middleware.py ├── tests │ ├── __init__.py │ ├── settings.py │ ├── side_effects.py │ ├── static │ │ └── dummy.css │ ├── test_checks.py │ ├── test_command.py │ ├── test_middleware.py │ └── test_views.py ├── urls.py ├── utils.py └── views.py ├── docker-compose.yml ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | .tox 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | push: 4 | branches: [main, test-publish] 5 | tags: '*' 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | - name: Install pypa/build 20 | run: >- 21 | python3 -m 22 | pip install 23 | build 24 | --user 25 | 26 | # If the event that triggered this workflow is a push of a tag, then build with 27 | # the version of that tag. 28 | - name: Build a binary wheel and a source tarball for tag 29 | run: BUILD_VERSION=${{ github.ref_name }} python3 -m build 30 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 31 | # The event that trigged this workflow is not a tag, so build without specifying the version. 32 | - name: Build a binary wheel and a source tarball 33 | run: python3 -m build 34 | if: "!startsWith(github.ref, 'refs/tags/')" 35 | 36 | - name: Store the distribution packages 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: python-package-distributions 40 | path: dist/ 41 | 42 | pypi-publish: 43 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 44 | needs: [build] 45 | name: Upload release to PyPI 46 | runs-on: ubuntu-latest 47 | environment: 48 | name: pypi 49 | url: https://pypi.org/p/django-alive 50 | permissions: 51 | id-token: write 52 | steps: 53 | - name: Download all the dists 54 | uses: actions/download-artifact@v4 55 | with: 56 | name: python-package-distributions 57 | path: dist/ 58 | - name: Publish distribution to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | 61 | github-release: 62 | name: >- 63 | Sign the Python 🐍 distribution 📦 with Sigstore 64 | and upload them to GitHub Release 65 | needs: 66 | - pypi-publish 67 | runs-on: ubuntu-latest 68 | 69 | permissions: 70 | contents: write # IMPORTANT: mandatory for making GitHub Releases 71 | id-token: write # IMPORTANT: mandatory for sigstore 72 | 73 | steps: 74 | - name: Download all the dists 75 | uses: actions/download-artifact@v4 76 | with: 77 | name: python-package-distributions 78 | path: dist/ 79 | - name: Sign the dists with Sigstore 80 | uses: sigstore/gh-action-sigstore-python@v2.1.1 81 | with: 82 | inputs: >- 83 | ./dist/*.tar.gz 84 | ./dist/*.whl 85 | - name: Create GitHub Release 86 | env: 87 | GITHUB_TOKEN: ${{ github.token }} 88 | run: >- 89 | gh release create 90 | '${{ github.ref_name }}' 91 | --repo '${{ github.repository }}' 92 | --generate-notes 93 | - name: Upload artifact signatures to GitHub Release 94 | env: 95 | GITHUB_TOKEN: ${{ github.token }} 96 | # Upload to GitHub Release using the `gh` CLI. 97 | # `dist/` contains the built packages, and the 98 | # sigstore-produced signatures and certificates. 99 | run: >- 100 | gh release upload 101 | '${{ github.ref_name }}' dist/** 102 | --repo '${{ github.repository }}' -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install tox 30 | run: python -m pip install tox 31 | 32 | - name: Run tox 33 | run: tox 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | .pytest_cache 3 | htmlcov 4 | .coverage 5 | /coverage_report 6 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.5 2 | 3.6.9 3 | 3.5.9 4 | 3.4.10 5 | 2.7.15 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | - "3.7" 9 | - "3.8" 10 | 11 | install: pip install tox-travis coverage codacy-coverage 12 | 13 | script: tox 14 | 15 | after_success: 16 | - coverage xml 17 | - python-codacy-coverage -r coverage.xml 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.0.0 (2024-08-30) 2 | ------------------ 3 | 4 | - Allow executing checks multiple times with different parameters; `ALIVE_CHECKS` should now be a list of tuples (setting it to a dictionary results in a deprecation warning). 5 | 6 | 7 | 1.2.2 (2024-08-14) 8 | ------------------ 9 | 10 | - Add GitHub Action for running tests against supported versions 11 | - Release from GitHub Actions 12 | 13 | 14 | 1.2.1 (2021-07-23) 15 | ------------------ 16 | 17 | * Update PyPI metadata 18 | 19 | 20 | 1.2.0 (2021-07-23) 21 | ------------------ 22 | 23 | * Updated test matrix. Python 2 no longer "officially" supported. 24 | * Prevent Traceback in middleware if URLs are not setup 25 | 26 | 27 | 1.1.0 (2019-11-06) 28 | ------------------ 29 | 30 | * Added `healthcheck` management command 31 | * Added optional `check_migrations` healthcheck 32 | 33 | 34 | 1.0.1 (2018-09-10) 35 | ------------------ 36 | 37 | * Documentation improvements 38 | * Python 3.7 support 39 | 40 | 41 | 1.0.0 (2018-08-21) 42 | ------------------ 43 | 44 | * Initial release 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /app 4 | COPY requirements-dev.txt setup.cfg setup.py ./ 5 | COPY django_alive/__init__.py ./django_alive 6 | RUN python -m pip install -r requirements-dev.txt -e . 7 | CMD pytest 8 | COPY . . 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All original code is provided under the MIT License: 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2018 Lincoln Loop, LLC 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft django_alive/tests/static 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --warn-undefined-variables 2 | SHELL := bash 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .DEFAULT_GOAL := all 5 | .DELETE_ON_ERROR: 6 | .SUFFIXES: 7 | 8 | .PHONY: check 9 | check: 10 | pytest 11 | 12 | .PHONY: fmt 13 | fmt: 14 | isort -rc django_alive 15 | black django_alive 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-alive 🕺 2 | 3 | [![tests](https://github.com/lincolnloop/django-alive/actions/workflows/tox.yml/badge.svg)](https://github.com/lincolnloop/django-alive/actions/workflows/tox.yml) 4 | [![coverage](https://img.shields.io/codacy/coverage/5d539d4956a44f55aec632f3a43ee6c1.svg)](https://app.codacy.com/project/ipmb/django-alive/dashboard) 5 | [![PyPI](https://img.shields.io/pypi/v/django-alive.svg)](https://pypi.org/project/django-alive/) 6 | ![Python Versions](https://img.shields.io/pypi/pyversions/django-alive.svg) 7 | 8 | Provides two healthcheck endpoints for your Django application: 9 | 10 | ### Alive 11 | 12 | Verifies the WSGI server is responding. 13 | 14 | * Default URL: `/-/alive/` 15 | * Success: 16 | * status code: `200` 17 | * content: `ok` 18 | * Failure: This view never returns a failure. A failure would mean your WSGI server is not running. 19 | 20 | ### Health 21 | 22 | Verifies services are ready. 23 | 24 | * Default URL: `/-/health/` 25 | * Success: 26 | * status_code: `200` 27 | * content: `{"healthy": true}` 28 | * Failure: 29 | * status_code: `503` 30 | * content: `{"healthy": false, "errors": ["error 1", "error 2"]}` 31 | 32 | By default the health endpoint will test the database connection, but can be configured to check the cache, staticfiles, or any additional custom checks. 33 | 34 | Supports Django 3.2+ on Python 3.6+. 35 | 36 | ## Install 37 | 38 | ``` 39 | pip install django-alive 40 | ``` 41 | 42 | ## Configure 43 | 44 | Add this to your project's `urlpatterns`: 45 | 46 | ```python 47 | path("-/", include("django_alive.urls")) 48 | ``` 49 | 50 | 51 | If you wish to use the `healthcheck` [management command](#management-command), add 52 | `django_alive` to the `INSTALLED_APPS`. 53 | 54 | ## Enabling Checks 55 | 56 | The default "health" endpoint will test a simple `SELECT 1` query on the database. Additional checks can be enabled in your Django settings. 57 | 58 | Use the `ALIVE_CHECKS` setting to configure the checks to include. It is a list of tuples with the path to a Python function as a first argiment and dict of keyword arguments to pass to that function as a second argument. A full example: 59 | 60 | ```python 61 | ALIVE_CHECKS = [ 62 | ("django_alive.checks.check_database", {}), 63 | ("django_alive.checks.check_staticfile", { 64 | "filename": "img/favicon.ico", 65 | }), 66 | ("django_alive.checks.check_cache", { 67 | "cache": "session", 68 | "key": "test123", 69 | }), 70 | ("django_alive.checks.check_migrations", {}), 71 | ] 72 | ``` 73 | 74 | **⚠️ Warning: Changed in version 1.3.0 ⚠️** 75 | 76 | **NOTE:** Old settings with `ALIVE_CHECKS` as dict was deprecated in favor of a list of tuples. 77 | 78 | 79 | ### Built-in Checks 80 | 81 | Defined in `django_alive.checks`. 82 | 83 | ```python 84 | def check_cache(key="django-alive", cache="default") 85 | ``` 86 | 87 | Fetch a cache key against the specified cache. 88 | 89 | #### Parameters: 90 | 91 | - `key` (`str`): Cache key to fetch (does not need to exist) 92 | - `cache` (`str`): Cache alias to execute against 93 | 94 | --- 95 | 96 | ```python 97 | def check_database(query="SELECT 1", database="default") 98 | ``` 99 | 100 | Run a SQL query against the specified database. 101 | 102 | #### Parameters: 103 | 104 | - `query` (`str`): SQL to execute 105 | - `database` (`str`): Database alias to execute against 106 | 107 | --- 108 | 109 | ```python 110 | def check_migrations(alias=None) 111 | ``` 112 | 113 | Verify all defined migrations have been applied 114 | 115 | #### Parameters: 116 | 117 | - `alias` (`str`): An optional database alias (default: check all defined databases) 118 | 119 | --- 120 | 121 | ```python 122 | def check_staticfile(filename) 123 | ``` 124 | 125 | Verify a static file is reachable 126 | 127 | #### Parameters: 128 | 129 | - `filename` (`str`): static file to verify 130 | 131 | ## Management Command 132 | 133 | In addition to the view, the configured healthchecks can also be run via a management command with `manage.py healthcheck`. This will exit with an error code if all the healthchecks do not pass. 134 | 135 | ## Custom Checks 136 | 137 | `django-alive` is designed to easily extend with your own custom checks. Simply define a function which performs your check and raises a `django_alive.HealthcheckFailure` exception in the event of a failure. See [`checks.py`](https://github.com/lincolnloop/django-alive/blob/master/django_alive/checks.py) for some examples on how to write a check. 138 | 139 | ## Disabling `ALLOWED_HOSTS` for Healthchecks 140 | 141 | Often, load balancers will not pass a `Host` header when probing a healthcheck endpoint. This presents a problem for [Django's host header validation](https://docs.djangoproject.com/en/2.1/topics/security/#host-headers-virtual-hosting). A middleware is included that will turn off the host checking only for the healthcheck endpoints. This is safe since these views never do anything with the `Host` header. 142 | 143 | Enable the middleware by inserting this **at the beginning** of your `MIDDLEWARE`: 144 | 145 | ```python 146 | MIDDLEWARE = [ 147 | "django_alive.middleware.healthcheck_bypass_host_check", 148 | # ... 149 | ] 150 | ``` 151 | 152 | ## Handling `SECURE_SSL_REDIRECT` 153 | 154 | If your load balancer is doing HTTPS termination and you have `SECURE_SSL_REDIRECT=True` in your settings, you want to make sure that your healtcheck URLs are not also redirected to HTTPS. In that case, add the following to your settings: 155 | 156 | ```python 157 | SECURE_REDIRECT_EXEMPT = [r"^-/"] # django-alive URLs 158 | ``` 159 | -------------------------------------------------------------------------------- /django_alive/__init__.py: -------------------------------------------------------------------------------- 1 | class HealthcheckFailure(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /django_alive/checks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.staticfiles.storage import staticfiles_storage 4 | from django.core.cache import caches 5 | from django.db import connections 6 | from django.db.migrations.executor import MigrationExecutor 7 | 8 | from . import HealthcheckFailure 9 | 10 | # don't bother with typing on Python <3.5 11 | try: 12 | from typing import Optional 13 | except ImportError: 14 | pass 15 | 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def check_database(query="SELECT 1", database="default"): 21 | # type: (str, str) -> None 22 | """ 23 | Run a SQL query against the specified database. 24 | 25 | :param str query: SQL to execute 26 | :param str database: Database alias to execute against 27 | :return None: 28 | """ 29 | try: 30 | with connections[database].cursor() as cursor: 31 | cursor.execute(query) 32 | except Exception: 33 | log.exception("%s database connection failed", database) 34 | raise HealthcheckFailure("database error") 35 | 36 | 37 | def check_staticfile(filename): 38 | # type: (str) -> None 39 | """ 40 | Verify a static file is reachable 41 | 42 | :param str filename: static file to verify 43 | :return None: 44 | """ 45 | if not staticfiles_storage.exists(filename): 46 | log.error("Can't find %s in static files.", filename) 47 | raise HealthcheckFailure("static files error") 48 | 49 | 50 | def check_cache(key="django-alive", cache="default"): 51 | # type: (str, str) -> None 52 | """ 53 | Fetch a cache key against the specified cache. 54 | 55 | :param str key: Cache key to fetch (does not need to exist) 56 | :param str cache: Cache alias to execute against 57 | :return None: 58 | """ 59 | try: 60 | caches[cache].get(key) 61 | except Exception: 62 | log.exception("%s cache connection failed", cache) 63 | raise HealthcheckFailure("cache error") 64 | 65 | 66 | def check_migrations(alias=None): 67 | # type: (Optional[str]) -> None 68 | """ 69 | Verify all defined migrations have been applied 70 | 71 | :param str alias: An optional database alias (default: check all defined databases) 72 | """ 73 | for db_conn in connections.all(): 74 | if alias and db_conn.alias != alias: 75 | continue 76 | executor = MigrationExecutor(db_conn) 77 | plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) 78 | if plan: 79 | log.error("Migrations pending on '%s' database", db_conn.alias) 80 | raise HealthcheckFailure("database migrations pending") 81 | -------------------------------------------------------------------------------- /django_alive/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/django-alive/15f1e39a6d0bd21c65e4c0e5608ac5e3dc77f930/django_alive/management/__init__.py -------------------------------------------------------------------------------- /django_alive/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/django-alive/15f1e39a6d0bd21c65e4c0e5608ac5e3dc77f930/django_alive/management/commands/__init__.py -------------------------------------------------------------------------------- /django_alive/management/commands/healthcheck.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from django_alive.utils import perform_healthchecks 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Perform healthchecks" 8 | 9 | def handle(self, *args, **options): 10 | healthy, errors = perform_healthchecks() 11 | if not healthy: 12 | raise CommandError("Not Healthy: {}".format("\n - ".join(errors))) 13 | self.stdout.write("OK") 14 | -------------------------------------------------------------------------------- /django_alive/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.urls import NoReverseMatch, reverse 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | 8 | def healthcheck_bypass_host_check(get_response): 9 | try: 10 | healthcheck_urls = [reverse("alive_alive"), reverse("alive_health")] 11 | except NoReverseMatch: 12 | log.warning("django-alive URLs have not been added to urlconf") 13 | healthcheck_urls = [] 14 | 15 | def middleware(request): 16 | if request.path in healthcheck_urls: 17 | request.get_host = request._get_raw_host 18 | 19 | return get_response(request) 20 | 21 | return middleware 22 | -------------------------------------------------------------------------------- /django_alive/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/django-alive/15f1e39a6d0bd21c65e4c0e5608ac5e3dc77f930/django_alive/tests/__init__.py -------------------------------------------------------------------------------- /django_alive/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | try: 4 | from django.urls import include, re_path 5 | except ImportError: 6 | from django.conf.urls import include, url as re_path 7 | 8 | INSTALLED_APPS = ["django_alive"] 9 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} 10 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 11 | ROOT_URLCONF = "django_alive.tests.settings" 12 | SECRET_KEY = "secret" 13 | 14 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), "static") 15 | STATIC_URL = "/static/" 16 | 17 | 18 | urlpatterns = [re_path(r"-/", include("django_alive.urls"))] 19 | -------------------------------------------------------------------------------- /django_alive/tests/side_effects.py: -------------------------------------------------------------------------------- 1 | from .. import checks 2 | 3 | ERR_MSG = "database failed" 4 | 5 | 6 | def bad_database_check(*args, **kwargs): 7 | raise checks.HealthcheckFailure(ERR_MSG) 8 | -------------------------------------------------------------------------------- /django_alive/tests/static/dummy.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/django-alive/15f1e39a6d0bd21c65e4c0e5608ac5e3dc77f930/django_alive/tests/static/dummy.css -------------------------------------------------------------------------------- /django_alive/tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils.module_loading import import_string 3 | 4 | from .. import checks 5 | 6 | try: 7 | from unittest.mock import patch 8 | except ImportError: 9 | from mock import patch 10 | 11 | 12 | class TestChecks(TestCase): 13 | def test_database(self): 14 | self.assertIsNone(checks.check_database()) 15 | 16 | def test_database_failed(self): 17 | with patch("django.db.connection.cursor", return_value=None): 18 | self.assertRaises(checks.HealthcheckFailure, checks.check_database) 19 | 20 | def test_staticfiles(self): 21 | self.assertIsNone(checks.check_staticfile("dummy.css")) 22 | 23 | def test_staticfiles_failed(self): 24 | with self.assertRaises(checks.HealthcheckFailure): 25 | checks.check_staticfile("does-not=exist.css") 26 | 27 | def test_cache(self): 28 | self.assertIsNone(checks.check_cache()) 29 | 30 | def test_cache_failed(self): 31 | def broken_get(*args, **kwargs): 32 | raise ValueError 33 | 34 | with patch( 35 | "django.core.cache.backends.locmem.LocMemCache.get", side_effect=broken_get 36 | ): 37 | self.assertRaises(checks.HealthcheckFailure, checks.check_cache) 38 | 39 | def test_migrations(self): 40 | def migration_plan(*args, **kwargs): 41 | return [ 42 | ( 43 | import_string( 44 | "django.contrib.contenttypes.migrations.0001_initial.Migration" 45 | ), 46 | False, 47 | ) 48 | ] 49 | 50 | with patch( 51 | "django.db.migrations.executor.MigrationExecutor.migration_plan", 52 | return_value=migration_plan, 53 | ): 54 | self.assertRaises(checks.HealthcheckFailure, checks.check_migrations) 55 | -------------------------------------------------------------------------------- /django_alive/tests/test_command.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management import CommandError, call_command 4 | from django.test import TestCase 5 | 6 | from .side_effects import bad_database_check 7 | 8 | try: 9 | from unittest.mock import patch 10 | except ImportError: 11 | from mock import patch 12 | 13 | # Python 2.7 support 14 | if sys.version_info > (3, 0): 15 | from io import StringIO 16 | else: 17 | from io import BytesIO as StringIO 18 | 19 | 20 | class CommandTestCase(TestCase): 21 | def test_command(self): 22 | out = StringIO() 23 | call_command("healthcheck", stdout=out) 24 | self.assertIn("OK", out.getvalue()) 25 | 26 | def test_command_failed(self): 27 | with patch( 28 | "django_alive.checks.check_database", side_effect=bad_database_check 29 | ): 30 | with self.assertRaises(CommandError): 31 | call_command("healthcheck") 32 | -------------------------------------------------------------------------------- /django_alive/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import DisallowedHost 2 | from django.test import RequestFactory, TestCase, override_settings 3 | from django.urls import reverse 4 | 5 | from ..middleware import healthcheck_bypass_host_check 6 | 7 | 8 | class MiddlewareTestCase(TestCase): 9 | def setUp(self): 10 | self.middleware = healthcheck_bypass_host_check(lambda r: r) 11 | 12 | @override_settings(ALLOWED_HOSTS=["not-testserver"]) 13 | def test_bypass(self): 14 | request = RequestFactory().get(reverse("alive_alive")) 15 | passed_request = self.middleware(request) 16 | self.assertEqual(request, passed_request) 17 | 18 | @override_settings(ALLOWED_HOSTS=["not-testserver"]) 19 | def test_disallowed(self): 20 | request = RequestFactory().get("/") 21 | request = self.middleware(request) 22 | self.assertRaises(DisallowedHost, request.get_host) 23 | -------------------------------------------------------------------------------- /django_alive/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase, override_settings 4 | from django.urls import reverse 5 | 6 | from .side_effects import ERR_MSG, bad_database_check 7 | 8 | try: 9 | from unittest.mock import patch 10 | except ImportError: 11 | from mock import patch 12 | 13 | 14 | class ViewTestCase(TestCase): 15 | def test_liveness(self): 16 | response = self.client.get(reverse("alive_alive")) 17 | self.assertEqual(response.status_code, 200) 18 | self.assertEqual(response.content.decode("utf8"), "ok") 19 | 20 | def test_healthcheck(self): 21 | response = self.client.get(reverse("alive_health")) 22 | self.assertEqual(response.status_code, 200) 23 | self.assertEqual(json.loads(response.content.decode("utf8")), {"healthy": True}) 24 | 25 | @override_settings(ALIVE_CHECKS={"django_alive.checks.check_migrations": {}}) 26 | def test_deprecated_dict_format(self): 27 | with self.assertWarns(DeprecationWarning): 28 | response = self.client.get(reverse("alive_health")) 29 | self.assertEqual(response.status_code, 200) 30 | self.assertEqual(json.loads(response.content.decode("utf8")), {"healthy": True}) 31 | 32 | def test_healtcheck_failed(self): 33 | 34 | with patch( 35 | "django_alive.checks.check_database", side_effect=bad_database_check 36 | ): 37 | response = self.client.get(reverse("alive_health")) 38 | self.assertEqual(response.status_code, 503) 39 | self.assertEqual( 40 | json.loads(response.content.decode("utf8")), 41 | {"healthy": False, "errors": [ERR_MSG]}, 42 | ) 43 | -------------------------------------------------------------------------------- /django_alive/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import re_path 3 | except ImportError: 4 | from django.conf.urls import url as re_path 5 | 6 | from . import views 7 | 8 | urlpatterns = [ 9 | re_path(r"^alive/$", views.alive, name="alive_alive"), 10 | re_path(r"^health/$", views.healthcheck, name="alive_health"), 11 | ] 12 | -------------------------------------------------------------------------------- /django_alive/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from django.conf import settings 3 | from django.utils.module_loading import import_string 4 | 5 | from . import HealthcheckFailure 6 | 7 | # Ignore typing on Python <3.5 8 | try: 9 | from typing import List 10 | except ImportError: 11 | pass 12 | 13 | 14 | DEFAULT_ALIVE_CHECKS = [("django_alive.checks.check_database", {})] 15 | 16 | 17 | def perform_healthchecks(): 18 | # typing: () -> (bool, List[str]) 19 | errors = [] 20 | ALIVE_CHECKS = getattr(settings, "ALIVE_CHECKS", DEFAULT_ALIVE_CHECKS) 21 | if isinstance(ALIVE_CHECKS, dict): 22 | # Deprecated dict format 23 | warnings.warn( 24 | "ALIVE_CHECKS should be a list of tuples, not a dict. " 25 | "Please update your settings.", 26 | DeprecationWarning, 27 | ) 28 | checks = ALIVE_CHECKS.items() 29 | else: 30 | checks = ALIVE_CHECKS 31 | for func, kwargs in checks: 32 | try: 33 | import_string(func)(**kwargs) 34 | except HealthcheckFailure as e: 35 | errors.append(str(e)) 36 | return not errors, errors 37 | -------------------------------------------------------------------------------- /django_alive/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http import HttpResponse, JsonResponse 4 | 5 | from .utils import perform_healthchecks 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def alive(request): 11 | return HttpResponse("ok") 12 | 13 | 14 | def healthcheck(request): 15 | # Verify DB is connected 16 | healthy, errors = perform_healthchecks() 17 | response = {"healthy": healthy} 18 | if healthy: 19 | status = 200 20 | else: 21 | status = 503 22 | response.update({"errors": errors}) 23 | 24 | return JsonResponse(response, status=status) 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | test: 5 | build: . 6 | volumes: 7 | - .:/app 8 | - ~/.pypirc:/root/.pypirc:ro 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | isort 3 | zest.releaser 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-alive 3 | version = 1.2.2.dev0 4 | description = Healtchecks for Django 5 | long_description = file: README.md, CHANGELOG.md 6 | long_description_content_type = text/markdown 7 | author = Peter Baumgartner 8 | author_email = pete@lincolnloop.com 9 | url = https://github.com/lincolnloop/django-alive/ 10 | keywords = django, healtcheck, alive 11 | license = MIT 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Environment :: Web Environment 15 | License :: OSI Approved :: MIT License 16 | Operating System :: OS Independent 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | Programming Language :: Python :: 3.12 25 | Framework :: Django 26 | 27 | [options] 28 | packages = find: 29 | install_requires = 30 | django 31 | 32 | [options.extras_require] 33 | test = 34 | pytest 35 | pytest-cov 36 | pytest-django 37 | 38 | [bdist_wheel] 39 | universal = 1 40 | 41 | [zest.releaser] 42 | tag-signing = yes 43 | 44 | [coverage:run] 45 | source = django_alive 46 | omit = 47 | django_alive/tests/* 48 | 49 | [coverage:report] 50 | show_missing = true 51 | skip_covered = true 52 | 53 | [tool:pytest] 54 | DJANGO_SETTINGS_MODULE = django_alive.tests.settings 55 | addopts = --pyargs 56 | testpaths = django_alive 57 | filterwarnings = all 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import setuptools 4 | setuptools.setup(version=os.environ.get("BUILD_VERSION")) 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | begin 4 | py{36,37,38,39,310}-django-{32} 5 | py{38,39,310}-django-{40} 6 | py{38,39,310,311}-django-{41} 7 | py{38,39,310,311,312}-django-{42} 8 | py{310,311,312}-django-{50,51} 9 | end 10 | skip_missing_interpreters = True 11 | 12 | [testenv] 13 | usedevelop = True 14 | extras = test 15 | deps = 16 | django-32: Django==3.2.* 17 | django-40: Django==4.0.* 18 | django-41: Django==4.1.* 19 | django-42: Django==4.2.* 20 | django-50: Django==5.0.* 21 | django-51: Django>=5.1a1,<5.2 22 | setenv = 23 | DJANGO_SETTINGS_MODULE=django_alive.tests.settings 24 | commands= 25 | pytest --cov --cov-append --cov-report= 26 | 27 | [testenv:begin] 28 | basepython = python3.6 29 | skip_install = True 30 | deps = coverage 31 | commands = coverage erase 32 | 33 | [testenv:end] 34 | basepython = python3.6 35 | skip_install = True 36 | deps = coverage 37 | commands= 38 | coverage report 39 | coverage html --directory=coverage_report 40 | --------------------------------------------------------------------------------