├── tests ├── testproject │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── manage.py └── unit │ └── test_cli.py ├── django_probes ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── wait_for_database.py └── __init__.py ├── MANIFEST.in ├── .gitignore ├── .github └── workflows │ ├── publish.yml │ ├── check.yml │ └── test.yml ├── LICENSE ├── tox.ini ├── pyproject.toml └── README.rst /tests/testproject/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_probes/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_probes/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | exclude tests 4 | -------------------------------------------------------------------------------- /django_probes/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Make Django wait until database is ready. Probes for Docker and Kubernetes. 3 | """ 4 | 5 | __version__ = "1.8.0" 6 | -------------------------------------------------------------------------------- /tests/testproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | url(r"^admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs & editors 2 | /.idea/ 3 | /.vscode/ 4 | 5 | # cache 6 | *.py[cod] 7 | __pycache__/ 8 | 9 | # virtualenv 10 | /venv/ 11 | /virtualenv/ 12 | 13 | requirements.txt 14 | uv.lock 15 | 16 | # packaging 17 | /*.egg-info/ 18 | /build/ 19 | /dist/ 20 | 21 | # testing 22 | /.cache/ 23 | /.pytest_cache/ 24 | /.tox/ 25 | /pytestdebug.log 26 | /tests/*-report.json 27 | /tests/*-report.xml 28 | /tests/testproject.sqlite 29 | .coverage 30 | -------------------------------------------------------------------------------- /tests/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ 6 | """ 7 | 8 | import os 9 | 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 13 | 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | PIP_DISABLE_PIP_VERSION_CHECK: '1' 10 | PY_COLORS: '1' 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | - uses: actions/setup-python@v6 18 | with: 19 | python-version: '3.13' 20 | - name: Install build tools 21 | run: python -m pip install build twine wheel 22 | - name: Build package 23 | run: python -m build 24 | - name: Upload to PyPI 25 | env: 26 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: twine upload dist/* 29 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | env: 12 | PIP_DISABLE_PIP_VERSION_CHECK: '1' 13 | PY_COLORS: '1' 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | env: 22 | - lint 23 | - format 24 | - audit 25 | - package 26 | steps: 27 | - uses: actions/checkout@v6 28 | - uses: actions/setup-python@v6 29 | with: 30 | python-version: '3.13' 31 | - name: Install build tools 32 | run: python -m pip install tox 33 | - name: Run ${{ matrix.env }} 34 | run: tox -e ${{ matrix.env }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | env: 12 | PIP_DISABLE_PIP_VERSION_CHECK: '1' 13 | PY_COLORS: '1' 14 | 15 | jobs: 16 | python-django: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | max-parallel: 5 20 | fail-fast: false 21 | matrix: 22 | python-version: 23 | - '3.10' 24 | - '3.11' 25 | - '3.12' 26 | - '3.13' 27 | django-version: 28 | - '4.2' 29 | - '5.1' 30 | - '5.2' 31 | include: 32 | - { python-version: '3.9', django-version: '4.2' } 33 | - { python-version: '3.14', django-version: '5.2' } 34 | exclude: 35 | - { python-version: '3.13', django-version: '4.2' } 36 | steps: 37 | - uses: actions/checkout@v6 38 | - uses: actions/setup-python@v6 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Install build tools 42 | run: python -m pip install tox-gh-actions 43 | - name: Run tests (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 44 | run: tox 45 | env: 46 | DJANGO: ${{ matrix.django-version }} 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019-2020 Peter Bittner 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint 4 | format 5 | audit 6 | # Python/Django combinations that are officially supported (minus end-of-life Pythons) 7 | py{39,310,311,312}-django{42} 8 | py{310,311,312,313}-django{51} 9 | py{310,311,312,313,314}-django{52} 10 | package 11 | clean 12 | 13 | [gh-actions] 14 | python = 15 | 3.9: py39 16 | 3.10: py310 17 | 3.11: py311 18 | 3.12: py312 19 | 3.13: py313 20 | 3.14: py314 21 | 22 | [gh-actions:env] 23 | DJANGO = 24 | 4.2: django42 25 | 5.1: django51 26 | 5.2: django52 27 | 28 | [testenv] 29 | description = Unit tests 30 | deps = 31 | coverage[toml] 32 | pytest-django 33 | django42: Django>=4.2,<5.0 34 | django51: Django>=5.1,<5.2 35 | django52: Django>=5.2,<6.0 36 | commands = 37 | coverage run -m pytest {posargs} 38 | coverage report 39 | 40 | [testenv:audit] 41 | description = Scan for vulnerable dependencies 42 | skip_install = true 43 | deps = 44 | pip-audit 45 | uv 46 | commands = 47 | uv export --no-emit-project --no-hashes -o requirements.txt -q 48 | pip-audit {posargs:-r requirements.txt --progress-spinner off} 49 | 50 | [testenv:clean] 51 | description = Remove Python bytecode and other debris 52 | skip_install = true 53 | deps = pyclean 54 | commands = pyclean {posargs:. --debris --erase requirements.txt uv.lock tests/testproject.sqlite --yes --verbose} 55 | 56 | [testenv:format] 57 | description = Ensure consistent code style (Ruff) 58 | skip_install = true 59 | deps = ruff 60 | commands = ruff format {posargs:--check --diff} 61 | 62 | [testenv:lint] 63 | description = Lightening-fast linting (Ruff) 64 | skip_install = true 65 | deps = ruff 66 | commands = ruff check {posargs} 67 | 68 | [testenv:mypy] 69 | description = Perform static type checking 70 | deps = mypy 71 | commands = mypy {posargs:.} 72 | 73 | [testenv:package] 74 | description = Build package and check metadata (or upload package) 75 | skip_install = true 76 | deps = 77 | build 78 | twine 79 | commands = 80 | python -m build 81 | twine {posargs:check --strict} dist/* 82 | passenv = 83 | TWINE_USERNAME 84 | TWINE_PASSWORD 85 | TWINE_REPOSITORY_URL 86 | -------------------------------------------------------------------------------- /tests/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/stable/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/stable/ref/settings/ 9 | """ 10 | 11 | from pathlib import Path 12 | 13 | BASE_DIR = Path(__file__).resolve().parent.parent 14 | 15 | # Quick-start development settings - unsuitable for production 16 | # See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/ 17 | 18 | # SECURITY WARNING: keep the secret key used in production secret! 19 | SECRET_KEY = "insecure-random-key" 20 | 21 | # SECURITY WARNING: don't run with debug turned on in production! 22 | DEBUG = False 23 | 24 | ALLOWED_HOSTS = ["*"] 25 | 26 | 27 | # Application definition 28 | 29 | INSTALLED_APPS = [ 30 | "django.contrib.admin", 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.messages", 35 | "django.contrib.staticfiles", 36 | "django_probes", 37 | ] 38 | 39 | MIDDLEWARE_CLASSES = [ 40 | "django.contrib.sessions.middleware.SessionMiddleware", 41 | "django.middleware.common.CommonMiddleware", 42 | "django.middleware.csrf.CsrfViewMiddleware", 43 | "django.contrib.auth.middleware.AuthenticationMiddleware", 44 | "django.contrib.auth.middleware.SessionAuthenticationMiddleware", 45 | "django.contrib.messages.middleware.MessageMiddleware", 46 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 47 | ] 48 | 49 | TEMPLATES = [ 50 | { 51 | "BACKEND": "django.template.backends.django.DjangoTemplates", 52 | "DIRS": [], 53 | "APP_DIRS": True, 54 | "OPTIONS": { 55 | "debug": DEBUG, 56 | "context_processors": [ 57 | "django.contrib.auth.context_processors.auth", 58 | ], 59 | }, 60 | }, 61 | ] 62 | 63 | ROOT_URLCONF = "testproject.urls" 64 | 65 | WSGI_APPLICATION = "testproject.wsgi.application" 66 | 67 | 68 | # Database 69 | # https://docs.djangoproject.com/en/stable/ref/settings/#databases 70 | 71 | DATABASES = { 72 | "default": { 73 | "ENGINE": "django.db.backends.sqlite3", 74 | "NAME": BASE_DIR / "tests" / "testproject.sqlite", 75 | }, 76 | } 77 | 78 | # Internationalization 79 | # https://docs.djangoproject.com/en/stable/topics/i18n/ 80 | 81 | LANGUAGE_CODE = "en-us" 82 | 83 | TIME_ZONE = "UTC" 84 | 85 | USE_I18N = True 86 | 87 | USE_TZ = True 88 | 89 | 90 | # Static files (CSS, JavaScript, Images) 91 | # https://docs.djangoproject.com/en/stable/howto/static-files/ 92 | 93 | STATIC_URL = "/static/" 94 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["setuptools>=80"] 4 | 5 | [project] 6 | name = "django-probes" 7 | dynamic = ["version"] 8 | description = "Make Django wait until database is ready. Probes for Docker and Kubernetes." 9 | readme = "README.rst" 10 | license = "BSD-3-Clause" 11 | license-files = ["LICENSE"] 12 | authors = [ 13 | {name = "Bashar Said", email = "bashar.said@vshn.ch"}, 14 | {name = "d9pouces", email = "github@19pouces.net"}, 15 | {name = "Jorrit", email = "jorrit@wehelpen.nl"}, 16 | {name = "Peter Bittner", email = "django@bittner.it"}, 17 | {name = "sambasan", email = "sambasan@users.noreply.github.com"}, 18 | {name = "Siming Yuan", email = "siyuan@cisco.com"}, 19 | {name = "Simon Rüegg", email = "simon.ruegg@vshn.ch"}, 20 | ] 21 | maintainers = [ 22 | {name = "Peter Bittner", email = "django@bittner.it"}, 23 | ] 24 | keywords=[ 25 | "containers", 26 | "django", 27 | "database", 28 | "probes", 29 | "docker", 30 | "kubernetes", 31 | ] 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Environment :: Web Environment", 35 | "Framework :: Django", 36 | "Framework :: Django :: 4.2", 37 | "Framework :: Django :: 5.1", 38 | "Framework :: Django :: 5.2", 39 | "Intended Audience :: Developers", 40 | "Operating System :: OS Independent", 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 3", 43 | "Programming Language :: Python :: 3 :: Only", 44 | "Programming Language :: Python :: 3.9", 45 | "Programming Language :: Python :: 3.10", 46 | "Programming Language :: Python :: 3.11", 47 | "Programming Language :: Python :: 3.12", 48 | "Programming Language :: Python :: 3.13", 49 | "Programming Language :: Python :: 3.14", 50 | "Topic :: Internet :: WWW/HTTP", 51 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 52 | "Topic :: Software Development :: Libraries :: Python Modules", 53 | ] 54 | requires-python = ">=3.9" 55 | dependencies = [ 56 | "django>=4.2", 57 | ] 58 | 59 | [project.urls] 60 | Homepage = "https://github.com/painless-software/django-probes" 61 | 62 | [tool.coverage.report] 63 | show_missing = true 64 | skip_covered = true 65 | 66 | [tool.coverage.run] 67 | source = ["django_probes"] 68 | 69 | [tool.pytest.ini_options] 70 | addopts = "--color=yes --verbose" 71 | DJANGO_SETTINGS_MODULE = "testproject.settings" 72 | python_files = ["tests.py","test_*.py","*_tests.py"] 73 | pythonpath = ["tests"] 74 | 75 | [tool.ruff.lint] 76 | extend-select = ["ALL"] 77 | extend-ignore = ["ANN", "D", "EM101", "TRY003"] 78 | 79 | [tool.ruff.lint.per-file-ignores] 80 | "tests/*.py" = ["S101"] 81 | "tests/testproject/settings.py" = ["S105"] 82 | "tests/unit/test_cli.py" = ["INP001", "PTH208"] 83 | "django_probes/management/commands/wait_for_database.py" = ["ARG002", "RUF012", "T201", "TRY301"] 84 | 85 | [tool.setuptools] 86 | packages = [ 87 | "django_probes", 88 | "django_probes.management", 89 | "django_probes.management.commands", 90 | ] 91 | 92 | [tool.setuptools.dynamic] 93 | version = {attr = "django_probes.__version__"} 94 | -------------------------------------------------------------------------------- /tests/unit/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Verify that ``python manage.py wait_for_database`` works fine. 3 | """ 4 | 5 | import os 6 | import tempfile 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | from django.core.management import CommandError, call_command 11 | from django.db import OperationalError 12 | 13 | from django_probes.management.commands.wait_for_database import wait_for_database 14 | 15 | CLI_PARAMS = { 16 | "wait_when_down": 1, 17 | "wait_when_alive": 1, 18 | "stable": 3, 19 | "timeout": 1, 20 | "database": "default", 21 | } 22 | 23 | 24 | @patch("django.db.connection.is_usable", return_value=True) 25 | @patch("django.db.connection.ensure_connection") 26 | def test_loops_stable_times(mock_ensure_conn, mock_is_usable): 27 | """ 28 | Database connection must be stable some consecutive times in a row. 29 | """ 30 | wait_for_database(**CLI_PARAMS) 31 | 32 | assert mock_ensure_conn.call_count == CLI_PARAMS["stable"] + 1 33 | assert mock_is_usable.call_count == CLI_PARAMS["stable"] + 1 34 | 35 | 36 | @patch("django.db.connection.is_usable", return_value=True) 37 | @patch("django.db.connection.ensure_connection") 38 | def test_can_call_through_management(mock_ensure_conn, mock_is_usable): 39 | """ 40 | Executing the management command works (w/o operational errors). 41 | """ 42 | call_command("wait_for_database", stable=0, timeout=0) 43 | 44 | assert mock_ensure_conn.called 45 | assert mock_is_usable.called 46 | 47 | 48 | @patch("django.db.connection.is_usable") 49 | @patch("django.db.connection.ensure_connection", side_effect=OperationalError()) 50 | def test_exception_caught_when_connection_absent(mock_ensure_conn, mock_is_usable): 51 | """ 52 | When database connection is absent related errors are caught. 53 | """ 54 | with pytest.raises(TimeoutError): 55 | wait_for_database(**CLI_PARAMS) 56 | 57 | assert mock_ensure_conn.called 58 | assert not mock_is_usable.called 59 | 60 | 61 | @patch("django.db.connection.is_usable", return_value=False) 62 | @patch("django.db.connection.ensure_connection") 63 | def test_exception_caught_when_unusable(mock_ensure_conn, mock_is_usable): 64 | """ 65 | When database connection unusable related errors are caught. 66 | """ 67 | with pytest.raises(TimeoutError): 68 | wait_for_database(**CLI_PARAMS) 69 | 70 | assert mock_ensure_conn.called 71 | assert mock_is_usable.called 72 | 73 | 74 | @patch("django.db.connection.ensure_connection", side_effect=OperationalError()) 75 | def test_command_error_raised_when_connection_absent(mock_ensure_conn): 76 | """ 77 | When database connection is absent the management command aborts. 78 | """ 79 | with pytest.raises(CommandError): 80 | call_command("wait_for_database", stable=0, timeout=0) 81 | 82 | assert mock_ensure_conn.called 83 | 84 | 85 | @patch("django.db.connection.is_usable", return_value=True) 86 | @patch("django.db.connection.ensure_connection") 87 | def test_can_call_through_management_with_commands(mock_ensure_conn, mock_is_usable): 88 | """ 89 | Executing the management command works (w/o operational errors). 90 | """ 91 | with tempfile.TemporaryDirectory() as dirname: 92 | call_command( 93 | "wait_for_database", 94 | "--stable=0", 95 | "--timeout=0", 96 | "-c", 97 | f'shell -c \'open("{dirname}/0", "w").close()\'', 98 | "-c", 99 | f'shell -c \'open("{dirname}/1", "w").close()\'', 100 | ) 101 | created_objects = set(os.listdir(dirname)) 102 | 103 | assert created_objects == {"0", "1"} 104 | assert mock_ensure_conn.called 105 | assert mock_is_usable.called 106 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django-probes |latest-version| 2 | ============================== 3 | 4 | |checks-status| |tests-status| |publish-status| |download-stats| |python-support| |license| 5 | 6 | Provides a Django management command to check whether the primary database 7 | is ready to accept connections. 8 | 9 | Run this command in a Kubernetes or OpenShift `Init Container`_ to make 10 | your Django application wait until the database is available (e.g. to run 11 | database migrations). 12 | 13 | Why Should I Use This App? 14 | -------------------------- 15 | 16 | ``wait_for_database`` is a *single* command for *all* database engines 17 | Django supports. It automatically checks the database you have configured 18 | in your Django project settings. No need to code a specific wait command 19 | for Postgres, MariaDB, Oracle, etc., no need to pull a database engine 20 | specific container just for running the database readiness check. 21 | 22 | .. |latest-version| image:: https://img.shields.io/pypi/v/django-probes.svg 23 | :alt: Latest version on PyPI 24 | :target: https://pypi.org/project/django-probes 25 | .. |download-stats| image:: https://img.shields.io/pypi/dm/django-probes.svg 26 | :alt: Monthly downloads from PyPI 27 | :target: https://pypistats.org/packages/django-probes 28 | .. |checks-status| image:: https://github.com/painless-software/django-probes/actions/workflows/check.yml/badge.svg 29 | :target: https://github.com/painless-software/django-probes/actions/workflows/check.yml 30 | :alt: GitHub Workflow Status 31 | .. |tests-status| image:: https://github.com/painless-software/django-probes/actions/workflows/test.yml/badge.svg 32 | :target: https://github.com/painless-software/django-probes/actions/workflows/test.yml 33 | :alt: GitHub Workflow Status 34 | .. |publish-status| image:: https://github.com/painless-software/django-probes/actions/workflows/publish.yml/badge.svg 35 | :target: https://github.com/painless-software/django-probes/actions/workflows/publish.yml 36 | :alt: GitHub Workflow Status 37 | .. |python-support| image:: https://img.shields.io/pypi/pyversions/django-probes.svg 38 | :alt: Python versions 39 | :target: https://pypi.org/project/django-probes 40 | .. |license| image:: https://img.shields.io/pypi/l/django-probes.svg 41 | :alt: Software license 42 | :target: https://github.com/painless-software/django-probes/blob/main/LICENSE 43 | 44 | .. _Init Container: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/ 45 | 46 | Installation 47 | ============ 48 | 49 | The easiest way to install django-probes is with pip or uv, e.g. 50 | 51 | .. code:: shell 52 | 53 | pip install django-probes 54 | 55 | .. code:: shell 56 | 57 | uv add django-probes 58 | 59 | Basic Usage 60 | =========== 61 | 62 | 1. Add django-probes to your Django application: 63 | 64 | .. code:: python 65 | 66 | INSTALLED_APPS = [ 67 | ... 68 | 'django_probes', 69 | ] 70 | 71 | 2. Add an `Init Container`_ to your Kubernetes/OpenShift deployment 72 | configuration, which calls the ``wait_for_database`` management command: 73 | 74 | .. code:: yaml 75 | 76 | - kind: Deployment 77 | apiVersion: apps/v1 78 | spec: 79 | template: 80 | spec: 81 | initContainers: 82 | - name: wait-for-database 83 | image: my-django-app:latest 84 | envFrom: 85 | - secretRef: 86 | name: django 87 | command: ['python', 'manage.py', 'wait_for_database'] 88 | 89 | Use with Your Own Command 90 | ------------------------- 91 | 92 | Alternatively, you can integrate the ``wait_for_database`` command in your 93 | own management command, and do things like database migration, load initial 94 | data, etc. with roughly the same Kubernetes setup as above. 95 | 96 | .. code:: python 97 | 98 | from django.core.management import call_command 99 | 100 | # ... 101 | call_command('wait_for_database') 102 | 103 | Command Line Options 104 | -------------------- 105 | 106 | The management command comes with sane defaults, which you can override 107 | if needed: 108 | 109 | :--command, -c: 110 | execute Django management command(s) when the database is ready. 111 | This option can be used multiple times, e.g. 112 | ``wait_for_database -c 'migrate' -c 'runserver --skip-checks'`` 113 | :--database: 114 | which database of ``settings.DATABASES`` to wait for, default: ``default`` 115 | :--stable, -s: 116 | how long to observe whether connection is stable (seconds), default: ``5`` 117 | :--timeout, -t: 118 | how long to wait for the database before timing out (seconds), default: ``180`` 119 | :--wait-when-alive, -a: 120 | delay between checks when database is up (seconds), default: ``1`` 121 | :--wait-when-down, -d: 122 | delay between checks when database is down (seconds), default: ``2`` 123 | -------------------------------------------------------------------------------- /django_probes/management/commands/wait_for_database.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django management command ``wait_for_database`` 3 | """ 4 | 5 | import shlex 6 | import sys 7 | from time import sleep, time 8 | 9 | from django.core.management import call_command 10 | from django.core.management.base import BaseCommand, CommandError 11 | from django.db import DEFAULT_DB_ALIAS, connections 12 | from django.db.utils import OperationalError 13 | 14 | 15 | def wait_for_database(**opts): 16 | """ 17 | The main loop waiting for the database connection to come up. 18 | """ 19 | wait_for_db_seconds = opts["wait_when_down"] 20 | alive_check_delay = opts["wait_when_alive"] 21 | stable_for_seconds = opts["stable"] 22 | timeout_seconds = opts["timeout"] 23 | db_alias = opts["database"] 24 | 25 | conn_alive_start = None 26 | connection = connections[db_alias] 27 | start = time() 28 | 29 | while True: 30 | # loop until we have a database connection or we run into a timeout 31 | while True: 32 | try: 33 | connection.ensure_connection() 34 | if not connection.is_usable(): 35 | raise OperationalError("Database connection not usable") 36 | if not conn_alive_start: 37 | conn_alive_start = time() 38 | break 39 | except OperationalError as err: 40 | conn_alive_start = None 41 | 42 | elapsed_time = int(time() - start) 43 | if elapsed_time >= timeout_seconds: 44 | raise TimeoutError( 45 | "Could not establish database connection.", 46 | ) from err 47 | 48 | err_message = str(err).strip() 49 | print( 50 | f"Waiting for database (cause: {err_message}) ... {elapsed_time}s", 51 | file=sys.stderr, 52 | flush=True, 53 | ) 54 | sleep(wait_for_db_seconds) 55 | 56 | uptime = int(time() - conn_alive_start) 57 | print(f"Connection alive for > {uptime}s", flush=True) 58 | 59 | if uptime >= stable_for_seconds: 60 | break 61 | 62 | sleep(alive_check_delay) 63 | 64 | 65 | class Command(BaseCommand): 66 | """ 67 | A readiness probe you can use for Kubernetes. 68 | 69 | If the database is ready, i.e. willing to accept connections 70 | and handling requests, then this call will exit successfully. Otherwise 71 | the command exits with an error status after reaching a timeout. 72 | """ 73 | 74 | help = "Probes for database availability" 75 | requires_system_checks = [] 76 | 77 | def add_arguments(self, parser): 78 | parser.add_argument( 79 | "--timeout", 80 | "-t", 81 | type=int, 82 | default=180, 83 | metavar="SECONDS", 84 | action="store", 85 | help="how long to wait for the database before " 86 | "timing out (seconds), default: 180", 87 | ) 88 | parser.add_argument( 89 | "--stable", 90 | "-s", 91 | type=int, 92 | default=5, 93 | metavar="SECONDS", 94 | action="store", 95 | help="how long to observe whether connection " 96 | "is stable (seconds), default: 5", 97 | ) 98 | parser.add_argument( 99 | "--wait-when-down", 100 | "-d", 101 | type=int, 102 | default=2, 103 | metavar="SECONDS", 104 | action="store", 105 | help="delay between checks when database is down (seconds), default: 2", 106 | ) 107 | parser.add_argument( 108 | "--wait-when-alive", 109 | "-a", 110 | type=int, 111 | default=1, 112 | metavar="SECONDS", 113 | action="store", 114 | help="delay between checks when database is up (seconds), default: 1", 115 | ) 116 | parser.add_argument( 117 | "--database", 118 | default=DEFAULT_DB_ALIAS, 119 | action="store", 120 | dest="database", 121 | help="which database of `settings.DATABASES` " 122 | 'to wait for. Defaults to the "default" ' 123 | "database.", 124 | ) 125 | parser.add_argument( 126 | "--command", 127 | "-c", 128 | default=[], 129 | action="append", 130 | dest="command", 131 | help=( 132 | "execute this management command when database is up" 133 | " (can be repeated multiple times)." 134 | ), 135 | ) 136 | 137 | def handle(self, *args, **options): 138 | """ 139 | Wait for a database connection to come up. Exit with error 140 | status when a timeout threshold is surpassed. 141 | """ 142 | commands = options.pop("command", []) 143 | try: 144 | wait_for_database(**options) 145 | except TimeoutError as err: 146 | raise CommandError(err) from err 147 | for command in commands: 148 | parts = shlex.split(command) 149 | executable, params = parts[0], parts[1:] 150 | call_command(executable, *params) 151 | --------------------------------------------------------------------------------