From e86bf33bf4a9ac989b9a88df8ba5fc00bc93bf3c Mon Sep 17 00:00:00 2001 From: David Andersson <51036209+jdkandersson@users.noreply.github.com> Date: Fri, 19 Apr 2024 02:31:46 +1000 Subject: [PATCH] feat: add experimental django extension and init profile (#534) * feat: add `django-framework` extension and init profile (#492) * Add django-framework extension * Apply suggestions from code review * correct ubuntu version * fix tests * add platforms to example * add missing platforms * include django extension in index * update doc * fix literalinclude Co-authored-by: Weii Wang Co-authored-by: Alex Lowe Co-authored-by: Tiago Nobrega --- .../example_django/example_django/asgi.py | 16 ++ .../example_django/example_django/settings.py | 123 +++++++++++++++ .../example_django/example_django/urls.py | 22 +++ .../example_django/example_django/wsgi.py | 16 ++ .../example/example_django/manage.py | 22 +++ .../example/requirements.txt | 1 + .../example/rockcraft.yaml | 13 ++ .../override_example/foobar/asgi.py | 16 ++ .../override_example/foobar/settings.py | 123 +++++++++++++++ .../override_example/foobar/urls.py | 22 +++ .../override_example/foobar/wsgi.py | 16 ++ .../override_example/manage.py | 22 +++ .../override_example/requirements.txt | 1 + .../override_example/rockcraft.yaml | 33 ++++ .../code/use-django-extension/task.yaml | 22 +++ docs/how-to/rocks/index.rst | 1 + docs/how-to/rocks/use-django-extension.rst | 49 ++++++ docs/reference/extensions.rst | 6 + extensions/django-framework/gunicorn.conf.py | 3 + .../django-framework/statsd-mapping.conf | 9 ++ pyproject.toml | 6 +- rockcraft/commands/init.py | 43 ++++- rockcraft/extensions/__init__.py | 3 +- rockcraft/extensions/gunicorn.py | 61 ++++++++ .../extension-django/example_django/.foobar | 0 .../example_django/example_django/asgi.py | 16 ++ .../example_django/example_django/settings.py | 123 +++++++++++++++ .../example_django/example_django/urls.py | 22 +++ .../example_django/example_django/wsgi.py | 16 ++ .../extension-django/example_django/manage.py | 22 +++ .../general/extension-django/requirements.txt | 1 + .../spread/general/extension-django/task.yaml | 36 +++++ tests/unit/extensions/test_gunicorn.py | 147 ++++++++++++++++++ 33 files changed, 1029 insertions(+), 3 deletions(-) create mode 100644 docs/how-to/code/use-django-extension/example/example_django/example_django/asgi.py create mode 100644 docs/how-to/code/use-django-extension/example/example_django/example_django/settings.py create mode 100644 docs/how-to/code/use-django-extension/example/example_django/example_django/urls.py create mode 100644 docs/how-to/code/use-django-extension/example/example_django/example_django/wsgi.py create mode 100755 docs/how-to/code/use-django-extension/example/example_django/manage.py create mode 100644 docs/how-to/code/use-django-extension/example/requirements.txt create mode 100644 docs/how-to/code/use-django-extension/example/rockcraft.yaml create mode 100644 docs/how-to/code/use-django-extension/override_example/foobar/asgi.py create mode 100644 docs/how-to/code/use-django-extension/override_example/foobar/settings.py create mode 100644 docs/how-to/code/use-django-extension/override_example/foobar/urls.py create mode 100644 docs/how-to/code/use-django-extension/override_example/foobar/wsgi.py create mode 100755 docs/how-to/code/use-django-extension/override_example/manage.py create mode 100644 docs/how-to/code/use-django-extension/override_example/requirements.txt create mode 100644 docs/how-to/code/use-django-extension/override_example/rockcraft.yaml create mode 100644 docs/how-to/code/use-django-extension/task.yaml create mode 100644 docs/how-to/rocks/use-django-extension.rst create mode 100644 extensions/django-framework/gunicorn.conf.py create mode 100644 extensions/django-framework/statsd-mapping.conf create mode 100644 tests/spread/general/extension-django/example_django/.foobar create mode 100644 tests/spread/general/extension-django/example_django/example_django/asgi.py create mode 100644 tests/spread/general/extension-django/example_django/example_django/settings.py create mode 100644 tests/spread/general/extension-django/example_django/example_django/urls.py create mode 100644 tests/spread/general/extension-django/example_django/example_django/wsgi.py create mode 100755 tests/spread/general/extension-django/example_django/manage.py create mode 100644 tests/spread/general/extension-django/requirements.txt create mode 100644 tests/spread/general/extension-django/task.yaml diff --git a/docs/how-to/code/use-django-extension/example/example_django/example_django/asgi.py b/docs/how-to/code/use-django-extension/example/example_django/example_django/asgi.py new file mode 100644 index 000000000..a25bac07c --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/example_django/example_django/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for example_django project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + +application = get_asgi_application() diff --git a/docs/how-to/code/use-django-extension/example/example_django/example_django/settings.py b/docs/how-to/code/use-django-extension/example/example_django/example_django/settings.py new file mode 100644 index 000000000..8ad22fb91 --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/example_django/example_django/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for example_django project. + +Generated by 'django-admin startproject' using Django 5.0. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-64wlq26vpv0ah(v1%t@l*yljw$lekb!o4a$us+wu0!x-^llqt(" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "example_django.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "example_django.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/docs/how-to/code/use-django-extension/example/example_django/example_django/urls.py b/docs/how-to/code/use-django-extension/example/example_django/example_django/urls.py new file mode 100644 index 000000000..64b8f7f74 --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/example_django/example_django/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for example_django project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/docs/how-to/code/use-django-extension/example/example_django/example_django/wsgi.py b/docs/how-to/code/use-django-extension/example/example_django/example_django/wsgi.py new file mode 100644 index 000000000..36a83afcb --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/example_django/example_django/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for example_django project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + +application = get_wsgi_application() diff --git a/docs/how-to/code/use-django-extension/example/example_django/manage.py b/docs/how-to/code/use-django-extension/example/example_django/manage.py new file mode 100755 index 000000000..77dced937 --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/example_django/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/docs/how-to/code/use-django-extension/example/requirements.txt b/docs/how-to/code/use-django-extension/example/requirements.txt new file mode 100644 index 000000000..68d357cf8 --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/requirements.txt @@ -0,0 +1 @@ +django \ No newline at end of file diff --git a/docs/how-to/code/use-django-extension/example/rockcraft.yaml b/docs/how-to/code/use-django-extension/example/rockcraft.yaml new file mode 100644 index 000000000..1e831b2a2 --- /dev/null +++ b/docs/how-to/code/use-django-extension/example/rockcraft.yaml @@ -0,0 +1,13 @@ +name: example-django +summary: A Django application +description: A rock packing a Django application +version: "0.1" +base: ubuntu@22.04 +license: Apache-2.0 + +extensions: + - django-framework + +platforms: + amd64: + arm64: diff --git a/docs/how-to/code/use-django-extension/override_example/foobar/asgi.py b/docs/how-to/code/use-django-extension/override_example/foobar/asgi.py new file mode 100644 index 000000000..a7dd64eb9 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/foobar/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for foobar project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foobar.settings") + +application = get_asgi_application() diff --git a/docs/how-to/code/use-django-extension/override_example/foobar/settings.py b/docs/how-to/code/use-django-extension/override_example/foobar/settings.py new file mode 100644 index 000000000..135c6a4a4 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/foobar/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for foobar project. + +Generated by 'django-admin startproject' using Django 5.0. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-q^h4op%sov*%=rw!!h&9_p5ym28oxrym7#r-sfc&6jka(6gkbj" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "foobar.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "foobar.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/docs/how-to/code/use-django-extension/override_example/foobar/urls.py b/docs/how-to/code/use-django-extension/override_example/foobar/urls.py new file mode 100644 index 000000000..1ebd72551 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/foobar/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for foobar project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/docs/how-to/code/use-django-extension/override_example/foobar/wsgi.py b/docs/how-to/code/use-django-extension/override_example/foobar/wsgi.py new file mode 100644 index 000000000..d3630abd5 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/foobar/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for foobar project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foobar.settings") + +application = get_wsgi_application() diff --git a/docs/how-to/code/use-django-extension/override_example/manage.py b/docs/how-to/code/use-django-extension/override_example/manage.py new file mode 100755 index 000000000..254226317 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foobar.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/docs/how-to/code/use-django-extension/override_example/requirements.txt b/docs/how-to/code/use-django-extension/override_example/requirements.txt new file mode 100644 index 000000000..68d357cf8 --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/requirements.txt @@ -0,0 +1 @@ +django \ No newline at end of file diff --git a/docs/how-to/code/use-django-extension/override_example/rockcraft.yaml b/docs/how-to/code/use-django-extension/override_example/rockcraft.yaml new file mode 100644 index 000000000..7908ae21a --- /dev/null +++ b/docs/how-to/code/use-django-extension/override_example/rockcraft.yaml @@ -0,0 +1,33 @@ +name: example-django +summary: A Django application +description: A rock packing a Django application +version: "0.1" +base: ubuntu@22.04 +license: Apache-2.0 + +extensions: + - django-framework + +platforms: + amd64: + arm64: + +services: + django: + command: /bin/python3 -m gunicorn -c /django/gunicorn.conf.py foobar.wsgi:application + +# [docs:parts-start] +parts: + django-framework/install-app: + plugin: dump + source: . + organize: + foobar: django/app/foobar + manage.py: django/app/manage.py + stage: + - django/app/foobar + - django/app/manage.py + prime: + - django/app/foobar + - django/app/manage.py +# [docs:parts-end] diff --git a/docs/how-to/code/use-django-extension/task.yaml b/docs/how-to/code/use-django-extension/task.yaml new file mode 100644 index 000000000..bb03f0d17 --- /dev/null +++ b/docs/how-to/code/use-django-extension/task.yaml @@ -0,0 +1,22 @@ +########################################### +# IMPORTANT +# Comments matter! +# The docs use the wrapping comments as +# markers for including said instructions +# as snippets in the docs. +########################################### +summary: test the "Use the django-framework extension" guide + +environment: + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS: "1" + EXAMPLE_DIR/example: example + EXAMPLE_DIR/override_example: override_example + +execute: | + cd $EXAMPLE_DIR + rockcraft pack --verbosity debug + +restore: | + cd $EXAMPLE_DIR + rm *.rock + rockcraft clean diff --git a/docs/how-to/rocks/index.rst b/docs/how-to/rocks/index.rst index 13f8a3402..bd68642cc 100644 --- a/docs/how-to/rocks/index.rst +++ b/docs/how-to/rocks/index.rst @@ -8,5 +8,6 @@ Publish a rock to a registry Migrate a Docker image to a chiselled rock Use the flask extension + Use the django extension Include local and remote files Override a plugin's build diff --git a/docs/how-to/rocks/use-django-extension.rst b/docs/how-to/rocks/use-django-extension.rst new file mode 100644 index 000000000..8da7ba977 --- /dev/null +++ b/docs/how-to/rocks/use-django-extension.rst @@ -0,0 +1,49 @@ +Use the django-framework extension +---------------------------------- + +.. note:: + The Django extension is compatible with the ``bare`` and ``ubuntu@22.04`` + bases. + +To use it, include ``extensions: [ django-framework ]`` in your +``rockcraft.yaml`` file. + +Example: + +.. literalinclude:: ../code/use-django-extension/example/rockcraft.yaml + :language: yaml + +Managing project files with the Django extension +------------------------------------------------ + +The extension will search for a directory named after the rock within the +Rockcraft project directory to transfer it into the rock image. The Django +project should have a directory named after the rock, and the ``wsgi.py`` +file within this directory must contain an object named ``application`` +to serve as the WSGI entry point. + +The following is a typical Rockcraft project that meets this requirement. + +.. code-block:: + + +-- example_django + | |-- example_django + | | |-- wsgi.py + | | +-- ... + | |-- manage.py + | |-- migrate.sh + | +-- some_app + | |-- views.py + | +-- ... + |-- requirements.txt + +-- rockcraft.yaml + +To override this behaviour and adopt a different project structure, add +the ``django-framework/install-app`` part to install the Django project in +the ``/django/app`` directory within the rock image and update the command +for the ``django`` service to point to the WSGI path of your project. + +.. literalinclude:: ../code/use-django-extension/override_example/rockcraft.yaml + :language: yaml + :start-after: [docs:parts-start] + :end-before: [docs:parts-end] diff --git a/docs/reference/extensions.rst b/docs/reference/extensions.rst index 4ad8047d8..72845a485 100644 --- a/docs/reference/extensions.rst +++ b/docs/reference/extensions.rst @@ -17,3 +17,9 @@ Gunicorn, in the rock image. Additionally, it transfers your project files to A statsd-exporter is installed alongside the Gunicorn server to export Gunicorn server metrics. + +The ``django-framework`` extension +---------------------------------- + +The Django extension is similar to the flask-framework extension but tailored +for Django applications. diff --git a/extensions/django-framework/gunicorn.conf.py b/extensions/django-framework/gunicorn.conf.py new file mode 100644 index 000000000..8cd388e86 --- /dev/null +++ b/extensions/django-framework/gunicorn.conf.py @@ -0,0 +1,3 @@ +bind = ["0.0.0.0:8000"] +chdir = "/django/app" +statsd_host = "localhost:9125" diff --git a/extensions/django-framework/statsd-mapping.conf b/extensions/django-framework/statsd-mapping.conf new file mode 100644 index 000000000..dc334fdbc --- /dev/null +++ b/extensions/django-framework/statsd-mapping.conf @@ -0,0 +1,9 @@ +mappings: + - match: gunicorn.request.status.* + name: django_response_code + labels: + status: $1 + - match: gunicorn.requests + name: django_requests + - match: gunicorn.request.duration + name: django_request_duration diff --git a/pyproject.toml b/pyproject.toml index c80d56172..8e962d3be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,11 @@ line_length = 88 extend-exclude = "docs/sphinx-starter-pack" [tool.pyright] -ignore = ["docs/sphinx-starter-pack", "docs/how-to/code"] +ignore = [ + "docs/sphinx-starter-pack", + "docs/how-to/code", + "tests/spread/general/extension-django/example_django" +] [tool.mypy] python_version = "3.10" diff --git a/rockcraft/commands/init.py b/rockcraft/commands/init.py index a5ef63a65..7e65e2901 100644 --- a/rockcraft/commands/init.py +++ b/rockcraft/commands/init.py @@ -118,6 +118,47 @@ class InitCommand(AppCommand): # - flask/app/static """ ), + "django-framework": textwrap.dedent( + """\ + name: {name} + base: ubuntu@22.04 # the base environment for this Django application + version: '0.1' # just for humans. Semantic versioning is recommended + summary: A summary of your Django application # 79 char long summary + description: | + This is {name}'s description. You have a paragraph or two to tell the + most important story about it. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the + container registries out there. + license: GPL-3.0 # your application's SPDX license + platforms: # The platforms this rock should be built on and run on + amd64: + + # To ensure the django-framework extension functions properly, your Django project + # should have a structure similar to the following with ./{snake_name}/{snake_name}/wsgi.py + # being the WSGI entry point and contain an application object. + # +-- {snake_name} + # | |-- {snake_name} + # | | |-- wsgi.py + # | | +-- ... + # | |-- manage.py + # | |-- migrate.sh + # | +-- some_app + # | |-- views.py + # | +-- ... + # |-- requirements.txt + # +-- rockcraft.yaml + + extensions: + - django-framework + + # Uncomment the sections you need and adjust according to your requirements. + # parts: + # django-framework/dependencies: + # stage-packages: + # # list required packages or slices for your Django application below. + # - libpq-dev + """ + ), } _DEFAULT_PROFILE = "simple" @@ -157,6 +198,6 @@ def run(self, parsed_args: "argparse.Namespace") -> None: name = "my-rock-name" emit.debug(f"Set project name to '{name}'") - context = {"name": name} + context = {"name": name, "snake_name": name.replace("-", "_").lower()} init(self._INIT_TEMPLATES[parsed_args.profile].format(**context)) diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py index b0265f48a..7ac287843 100644 --- a/rockcraft/extensions/__init__.py +++ b/rockcraft/extensions/__init__.py @@ -17,7 +17,7 @@ """Extension processor and related utilities.""" from ._utils import apply_extensions -from .gunicorn import FlaskFramework +from .gunicorn import DjangoFramework, FlaskFramework from .registry import get_extension_class, get_extension_names, register, unregister __all__ = [ @@ -28,4 +28,5 @@ "unregister", ] +register("django-framework", DjangoFramework) register("flask-framework", FlaskFramework) diff --git a/rockcraft/extensions/gunicorn.py b/rockcraft/extensions/gunicorn.py index a6db1eb63..3bbf5cce2 100644 --- a/rockcraft/extensions/gunicorn.py +++ b/rockcraft/extensions/gunicorn.py @@ -274,3 +274,64 @@ def check_project(self): ) if not self.yaml_data.get("services", {}).get("flask", {}).get("command"): self._check_wsgi_path() + + +class DjangoFramework(_GunicornBase): + """An extension for constructing Python applications based on the Django framework.""" + + @property + def name(self): + """Return the normalized name of the rockcraft project.""" + return self.yaml_data["name"].replace("-", "_").lower() + + @property + def default_wsgi_path(self): + """Return the default wsgi path for the Django project.""" + return f"{self.name}.wsgi:application" + + @property + @override + def wsgi_path(self) -> str: + """Return the wsgi path of the wsgi application.""" + return self.default_wsgi_path + + @property + @override + def framework(self) -> str: + """Return the wsgi framework name, e.g. flask, django.""" + return "django" + + @override + def gen_install_app_part(self) -> Dict[str, Any]: + """Return the prime list for the Flask project.""" + if "django-framework/install-app" not in self.yaml_data.get("parts", {}): + return { + "plugin": "dump", + "source": self.name, + "organize": {"*": "django/app/", ".*": "django/app/"}, + } + return {} + + def _check_wsgi_path(self): + wsgi_file = self.project_root / self.name / self.name / "wsgi.py" + if not wsgi_file.exists(): + raise ExtensionError( + f"django application can not be imported from {self.default_wsgi_path}, " + f"no wsgi.py file found in the project directory ({str(wsgi_file.parent)})." + ) + if not self.has_global_variable(wsgi_file, "application"): + raise ExtensionError( + "django application can not be imported from {self.default_wsgi_path}, " + "no variable named application in application.py" + ) + + @override + def check_project(self): + """Ensure this extension can apply to the current rockcraft project.""" + if not (self.project_root / "requirements.txt").exists(): + raise ExtensionError( + "missing requirements.txt file, django-framework extension " + "requires this file with Django specified as a dependency" + ) + if not self.yaml_data.get("services", {}).get("django", {}).get("command"): + self._check_wsgi_path() diff --git a/tests/spread/general/extension-django/example_django/.foobar b/tests/spread/general/extension-django/example_django/.foobar new file mode 100644 index 000000000..e69de29bb diff --git a/tests/spread/general/extension-django/example_django/example_django/asgi.py b/tests/spread/general/extension-django/example_django/example_django/asgi.py new file mode 100644 index 000000000..a25bac07c --- /dev/null +++ b/tests/spread/general/extension-django/example_django/example_django/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for example_django project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + +application = get_asgi_application() diff --git a/tests/spread/general/extension-django/example_django/example_django/settings.py b/tests/spread/general/extension-django/example_django/example_django/settings.py new file mode 100644 index 000000000..42218f18a --- /dev/null +++ b/tests/spread/general/extension-django/example_django/example_django/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for example_django project. + +Generated by 'django-admin startproject' using Django 5.0. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-64wlq26vpv0ah(v1%t@l*yljw$lekb!o4a$us+wu0!x-^llqt(" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "example_django.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "example_django.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/spread/general/extension-django/example_django/example_django/urls.py b/tests/spread/general/extension-django/example_django/example_django/urls.py new file mode 100644 index 000000000..64b8f7f74 --- /dev/null +++ b/tests/spread/general/extension-django/example_django/example_django/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for example_django project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/tests/spread/general/extension-django/example_django/example_django/wsgi.py b/tests/spread/general/extension-django/example_django/example_django/wsgi.py new file mode 100644 index 000000000..36a83afcb --- /dev/null +++ b/tests/spread/general/extension-django/example_django/example_django/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for example_django project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + +application = get_wsgi_application() diff --git a/tests/spread/general/extension-django/example_django/manage.py b/tests/spread/general/extension-django/example_django/manage.py new file mode 100755 index 000000000..77dced937 --- /dev/null +++ b/tests/spread/general/extension-django/example_django/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_django.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/spread/general/extension-django/requirements.txt b/tests/spread/general/extension-django/requirements.txt new file mode 100644 index 000000000..68d357cf8 --- /dev/null +++ b/tests/spread/general/extension-django/requirements.txt @@ -0,0 +1 @@ +django \ No newline at end of file diff --git a/tests/spread/general/extension-django/task.yaml b/tests/spread/general/extension-django/task.yaml new file mode 100644 index 000000000..a703a81b2 --- /dev/null +++ b/tests/spread/general/extension-django/task.yaml @@ -0,0 +1,36 @@ +summary: django extension test + +environment: + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS: "true" + +execute: | + + run_rockcraft init --name example-django --profile django-framework + run_rockcraft pack + + test -f example-django_0.1_amd64.rock + + # Ensure docker does not have this container image + docker rmi --force example-django + # Install container + sudo /snap/rockcraft/current/bin/skopeo --insecure-policy copy oci-archive:example-django_0.1_amd64.rock docker-daemon:example-django:latest + # Ensure container exists + docker images example-django | MATCH "example-django" + + # ensure container doesn't exist + docker rm -f example-django-container + + # test the django project is ready to run inside the container + docker run --rm --entrypoint /bin/python3 example-django -m gunicorn --chdir /django/app --check-config example_django.wsgi:application + docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/manage.py').is_file()" + docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/.foobar').is_file()" + + # test the default django service + docker run --name example-django-container -d -p 8138:8000 example-django + retry -n 5 --wait 2 curl localhost:8138 + [ "$(curl -sw '%{http_code}' -o /dev/null localhost:8138)" == "200" ] + +restore: | + rm -f example-django_0.1_amd64.rock + docker rmi -f example-django + docker rm -f example-django-container diff --git a/tests/unit/extensions/test_gunicorn.py b/tests/unit/extensions/test_gunicorn.py index ae3a29cf5..22bc84909 100644 --- a/tests/unit/extensions/test_gunicorn.py +++ b/tests/unit/extensions/test_gunicorn.py @@ -34,11 +34,18 @@ def flask_input_yaml_fixture(): } +@pytest.fixture +def django_extension(mock_extensions, monkeypatch): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + extensions.register("django-framework", extensions.DjangoFramework) + + @pytest.fixture(name="django_input_yaml") def django_input_yaml_fixture(): return { "name": "foo-bar", "base": "ubuntu@22.04", + "platforms": {"amd64": {}}, "extensions": ["django-framework"], } @@ -362,3 +369,143 @@ def test_flask_extension_flask_service_override_disable_wsgi_path_check(tmp_path } extensions.apply_extensions(tmp_path, flask_input_yaml) + + +@pytest.mark.usefixtures("django_extension") +def test_django_extension_default(tmp_path, django_input_yaml): + (tmp_path / "requirements.txt").write_text("django") + (tmp_path / "test").mkdir() + (tmp_path / "foo_bar" / "foo_bar").mkdir(parents=True) + (tmp_path / "foo_bar" / "foo_bar" / "wsgi.py").write_text("application = object()") + + applied = extensions.apply_extensions(tmp_path, django_input_yaml) + + source = applied["parts"]["django-framework/config-files"]["source"] + del applied["parts"]["django-framework/config-files"]["source"] + suffix = "share/rockcraft/extensions/django-framework" + assert source[-len(suffix) :].replace("\\", "/") == suffix + + assert applied == { + "base": "ubuntu@22.04", + "name": "foo-bar", + "parts": { + "django-framework/config-files": { + "organize": {"gunicorn.conf.py": "django/gunicorn.conf.py"}, + "plugin": "dump", + }, + "django-framework/dependencies": { + "plugin": "python", + "python-packages": ["gunicorn"], + "python-requirements": ["requirements.txt"], + "source": ".", + "stage-packages": ["python3-venv"], + }, + "django-framework/install-app": { + "organize": {"*": "django/app/", ".*": "django/app/"}, + "plugin": "dump", + "source": "foo_bar", + }, + "django-framework/runtime": { + "plugin": "nil", + "stage-packages": ["ca-certificates_data"], + }, + "django-framework/statsd-exporter": { + "build-snaps": ["go"], + "plugin": "go", + "source": "https://github.com/prometheus/statsd_exporter.git", + "source-tag": "v0.26.0", + }, + }, + "platforms": {"amd64": {}}, + "run_user": "_daemon_", + "services": { + "django": { + "after": ["statsd-exporter"], + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py foo_bar.wsgi:application", + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + }, + "statsd-exporter": { + "command": ( + "/bin/statsd_exporter --statsd.mapping-config=/statsd-mapping.conf " + "--statsd.listen-udp=localhost:9125 " + "--statsd.listen-tcp=localhost:9125" + ), + "override": "merge", + "startup": "enabled", + "summary": "statsd exporter service", + "user": "_daemon_", + }, + }, + } + + +@pytest.mark.usefixtures("django_extension") +def test_django_extension_override_install_app(tmp_path, django_input_yaml): + (tmp_path / "requirements.txt").write_text("django") + (tmp_path / "test").mkdir() + (tmp_path / "foobar").mkdir() + (tmp_path / "foobar" / "wsgi.py").write_text("application = object()") + django_input_yaml["parts"] = { + "django-framework/install-app": { + "plugin": "dump", + "source": ".", + "organize": {"foobar": "django/app"}, + } + } + django_input_yaml["services"] = { + "django": { + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py foobar.wsgi:application" + } + } + applied = extensions.apply_extensions(tmp_path, django_input_yaml) + assert applied["parts"]["django-framework/install-app"] == { + "plugin": "dump", + "source": ".", + "organize": {"foobar": "django/app"}, + } + + +@pytest.mark.usefixtures("django_extension") +def test_django_extension_incorrect_wsgi_path_error(tmp_path): + input_yaml = { + "name": "foobar", + "extensions": ["django-framework"], + "base": "bare", + } + (tmp_path / "requirements.txt").write_text("django") + + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, input_yaml) + + assert "wsgi:application" in str(exc) + + django_project_dir = tmp_path / "foobar" / "foobar" + django_project_dir.mkdir(parents=True) + (django_project_dir / "wsgi.py").write_text("app = object()") + + with pytest.raises(ExtensionError): + extensions.apply_extensions(tmp_path, input_yaml) + + (django_project_dir / "wsgi.py").write_text("application = object()") + + extensions.apply_extensions(tmp_path, input_yaml) + + +@pytest.mark.usefixtures("django_extension") +def test_django_extension_django_service_override_disable_wsgi_path_check(tmp_path): + (tmp_path / "requirements.txt").write_text("flask") + + input_yaml = { + "name": "foobar", + "extensions": ["django-framework"], + "base": "bare", + "services": { + "django": { + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py webapp:app" + } + }, + } + + extensions.apply_extensions(tmp_path, input_yaml)