diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 727e3f8..a35f9f2 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -12,9 +12,12 @@ jobs: secrets: inherit with: extra-arguments: -x --localstack-address 172.17.0.1 + charmcraft-repository: alithethird/charmcraft + charmcraft-ref: flask-async-worker pre-run-script: localstack-installation.sh - charmcraft-channel: latest/edge + # charmcraft-channel: latest/edge modules: '["test_charm.py", "test_cos.py", "test_database.py", "test_db_migration.py", "test_django.py", "test_django_integrations.py", "test_fastapi.py", "test_go.py", "test_integrations.py", "test_proxy.py", "test_workers.py"]' rockcraft-channel: latest/edge juju-channel: ${{ matrix.juju-version }} channel: 1.29-strict/stable + test-timeout: 30 diff --git a/.trivyignore b/.trivyignore index f39633c..6c2a020 100644 --- a/.trivyignore +++ b/.trivyignore @@ -4,3 +4,7 @@ CVE-2022-40897 CVE-2024-6345 # pebble: Calling Decoder.Decode on a message which contains deeply nested structures can cause a panic due to stack exhaustion CVE-2024-34156 +# pebble: Go stdlib +CVE-2024-45338 +# go-app: Go crypto lib +CVE-2024-45337 diff --git a/examples/django/charm/charmcraft.yaml b/examples/django/charm/charmcraft.yaml index 05bf5df..4be002f 100644 --- a/examples/django/charm/charmcraft.yaml +++ b/examples/django/charm/charmcraft.yaml @@ -50,9 +50,9 @@ config: type: string django-secret-key-id: description: >- - This configuration is similar to `django-secret-key`, but instead accepts a Juju user secret ID. - The secret should contain a single key, "value", which maps to the actual Django secret key. - To create the secret, run the following command: + This configuration is similar to `django-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual Django secret key. + To create the secret, run the following command: `juju add-secret my-django-secret-key value= && juju grant-secret my-django-secret-key django-k8s`, and use the outputted secret ID to configure this option. type: secret @@ -69,6 +69,9 @@ config: webserver-workers: description: The number of webserver worker processes for handling requests. type: int + webserver-worker-class: + description: The method of webserver worker processes for handling requests. Can be either 'gevent' or 'sync'. + type: string containers: django-app: resource: django-app-image diff --git a/examples/django/django_app/django_app/__init__.py b/examples/django/django_app/django_app/django_app/__init__.py similarity index 100% rename from examples/django/django_app/django_app/__init__.py rename to examples/django/django_app/django_app/django_app/__init__.py diff --git a/examples/django/django_app/django_app/asgi.py b/examples/django/django_app/django_app/django_app/asgi.py similarity index 100% rename from examples/django/django_app/django_app/asgi.py rename to examples/django/django_app/django_app/django_app/asgi.py diff --git a/examples/django/django_app/django_app/settings.py b/examples/django/django_app/django_app/django_app/settings.py similarity index 100% rename from examples/django/django_app/django_app/settings.py rename to examples/django/django_app/django_app/django_app/settings.py diff --git a/examples/django/django_app/django_app/urls.py b/examples/django/django_app/django_app/django_app/urls.py similarity index 100% rename from examples/django/django_app/django_app/urls.py rename to examples/django/django_app/django_app/django_app/urls.py diff --git a/examples/django/django_app/django_app/wsgi.py b/examples/django/django_app/django_app/django_app/wsgi.py similarity index 100% rename from examples/django/django_app/django_app/wsgi.py rename to examples/django/django_app/django_app/django_app/wsgi.py diff --git a/examples/django/django_app/manage.py b/examples/django/django_app/django_app/manage.py similarity index 100% rename from examples/django/django_app/manage.py rename to examples/django/django_app/django_app/manage.py diff --git a/examples/django/django_app/migrate.sh b/examples/django/django_app/django_app/migrate.sh similarity index 100% rename from examples/django/django_app/migrate.sh rename to examples/django/django_app/django_app/migrate.sh diff --git a/examples/django/django_app/testing/__init__.py b/examples/django/django_app/django_app/testing/__init__.py similarity index 100% rename from examples/django/django_app/testing/__init__.py rename to examples/django/django_app/django_app/testing/__init__.py diff --git a/examples/django/django_app/testing/admin.py b/examples/django/django_app/django_app/testing/admin.py similarity index 100% rename from examples/django/django_app/testing/admin.py rename to examples/django/django_app/django_app/testing/admin.py diff --git a/examples/django/django_app/testing/apps.py b/examples/django/django_app/django_app/testing/apps.py similarity index 100% rename from examples/django/django_app/testing/apps.py rename to examples/django/django_app/django_app/testing/apps.py diff --git a/examples/django/django_app/testing/migrations/__init__.py b/examples/django/django_app/django_app/testing/migrations/__init__.py similarity index 100% rename from examples/django/django_app/testing/migrations/__init__.py rename to examples/django/django_app/django_app/testing/migrations/__init__.py diff --git a/examples/django/django_app/testing/models.py b/examples/django/django_app/django_app/testing/models.py similarity index 100% rename from examples/django/django_app/testing/models.py rename to examples/django/django_app/django_app/testing/models.py diff --git a/examples/django/django_app/testing/tests.py b/examples/django/django_app/django_app/testing/tests.py similarity index 100% rename from examples/django/django_app/testing/tests.py rename to examples/django/django_app/django_app/testing/tests.py diff --git a/examples/django/django_app/testing/views.py b/examples/django/django_app/django_app/testing/views.py similarity index 100% rename from examples/django/django_app/testing/views.py rename to examples/django/django_app/django_app/testing/views.py diff --git a/examples/django/requirements.txt b/examples/django/django_app/requirements.txt similarity index 100% rename from examples/django/requirements.txt rename to examples/django/django_app/requirements.txt diff --git a/examples/django/rockcraft.yaml b/examples/django/django_app/rockcraft.yaml similarity index 100% rename from examples/django/rockcraft.yaml rename to examples/django/django_app/rockcraft.yaml diff --git a/examples/django/django_async_app/django_async_app/django_async_app/__init__.py b/examples/django/django_async_app/django_async_app/django_async_app/__init__.py new file mode 100644 index 0000000..e3979c0 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/django_async_app/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/examples/django/django_async_app/django_async_app/django_async_app/asgi.py b/examples/django/django_async_app/django_async_app/django_async_app/asgi.py new file mode 100644 index 0000000..76f2304 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/django_async_app/asgi.py @@ -0,0 +1,19 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +""" +ASGI config for django_async_app 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", "django_async_app.settings") + +application = get_asgi_application() diff --git a/examples/django/django_async_app/django_async_app/django_async_app/settings.py b/examples/django/django_async_app/django_async_app/django_async_app/settings.py new file mode 100644 index 0000000..614e67f --- /dev/null +++ b/examples/django/django_async_app/django_async_app/django_async_app/settings.py @@ -0,0 +1,130 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +""" +Django settings for django_async_app project. + +Generated by 'django-admin startproject' using Django 5.0.2. + +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/ +""" + +import json +import os +import urllib.parse +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 = os.environ.get("DJANGO_SECRET_KEY", "secret") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get("DJANGO_DEBUG", "true") == "true" + +ALLOWED_HOSTS = json.loads(os.environ["DJANGO_ALLOWED_HOSTS"]) + + +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 = "django_async_app.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 = "django_async_app.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRESQL_DB_NAME"), + "USER": os.environ.get("POSTGRESQL_DB_USERNAME"), + "PASSWORD": os.environ.get("POSTGRESQL_DB_PASSWORD"), + "HOST": os.environ.get("POSTGRESQL_DB_HOSTNAME"), + "PORT": os.environ.get("POSTGRESQL_DB_PORT", "5432"), + } +} + + +# 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/examples/django/django_async_app/django_async_app/django_async_app/urls.py b/examples/django/django_async_app/django_async_app/django_async_app/urls.py new file mode 100644 index 0000000..ba482f2 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/django_async_app/urls.py @@ -0,0 +1,28 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +""" +URL configuration for django_async_app 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 +from testing.views import sleep + +urlpatterns = [ + path("admin/", admin.site.urls), + path("sleep", sleep, name="sleep"), +] diff --git a/examples/django/django_async_app/django_async_app/django_async_app/wsgi.py b/examples/django/django_async_app/django_async_app/django_async_app/wsgi.py new file mode 100644 index 0000000..968f6f5 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/django_async_app/wsgi.py @@ -0,0 +1,19 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +""" +WSGI config for django_async_app 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", "django_async_app.settings") + +application = get_wsgi_application() diff --git a/examples/django/django_async_app/django_async_app/manage.py b/examples/django/django_async_app/django_async_app/manage.py new file mode 100755 index 0000000..6c134b1 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/manage.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_async_app.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/examples/django/django_async_app/django_async_app/testing/__init__.py b/examples/django/django_async_app/django_async_app/testing/__init__.py new file mode 100644 index 0000000..e3979c0 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/examples/django/django_async_app/django_async_app/testing/admin.py b/examples/django/django_async_app/django_async_app/testing/admin.py new file mode 100644 index 0000000..b111777 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/admin.py @@ -0,0 +1,6 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from django.contrib import admin + +# Register your models here. diff --git a/examples/django/django_async_app/django_async_app/testing/apps.py b/examples/django/django_async_app/django_async_app/testing/apps.py new file mode 100644 index 0000000..f435e0c --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/apps.py @@ -0,0 +1,9 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from django.apps import AppConfig + + +class TestingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "testing" diff --git a/examples/django/django_async_app/django_async_app/testing/migrations/__init__.py b/examples/django/django_async_app/django_async_app/testing/migrations/__init__.py new file mode 100644 index 0000000..e3979c0 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/migrations/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/examples/django/django_async_app/django_async_app/testing/models.py b/examples/django/django_async_app/django_async_app/testing/models.py new file mode 100644 index 0000000..dde0a81 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/models.py @@ -0,0 +1,6 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from django.db import models + +# Create your models here. diff --git a/examples/django/django_async_app/django_async_app/testing/tests.py b/examples/django/django_async_app/django_async_app/testing/tests.py new file mode 100644 index 0000000..922bda5 --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/tests.py @@ -0,0 +1,6 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from django.test import TestCase + +# Create your tests here. diff --git a/examples/django/django_async_app/django_async_app/testing/views.py b/examples/django/django_async_app/django_async_app/testing/views.py new file mode 100644 index 0000000..149129c --- /dev/null +++ b/examples/django/django_async_app/django_async_app/testing/views.py @@ -0,0 +1,12 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import time + +from django.http import HttpResponse + + +def sleep(request): + duration = request.GET.get("duration") + time.sleep(int(duration)) + return HttpResponse() diff --git a/examples/django/django_async_app/requirements.txt b/examples/django/django_async_app/requirements.txt new file mode 100644 index 0000000..4567a43 --- /dev/null +++ b/examples/django/django_async_app/requirements.txt @@ -0,0 +1,4 @@ +Django +tzdata +psycopg2-binary +gevent diff --git a/examples/django/django_async_app/rockcraft.yaml b/examples/django/django_async_app/rockcraft.yaml new file mode 100644 index 0000000..98b9e97 --- /dev/null +++ b/examples/django/django_async_app/rockcraft.yaml @@ -0,0 +1,14 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +name: django-async-app +summary: Example Async Django application image. +description: Example Async Django application image. +version: "0.1" +base: ubuntu@22.04 +license: Apache-2.0 +platforms: + amd64: + +extensions: + - django-framework diff --git a/examples/flask/charmcraft.yaml b/examples/flask/charmcraft.yaml index 292dba0..adb36bc 100644 --- a/examples/flask/charmcraft.yaml +++ b/examples/flask/charmcraft.yaml @@ -57,9 +57,9 @@ config: type: string flask-secret-key-id: description: >- - This configuration is similar to `flask-secret-key`, but instead accepts a Juju user secret ID. - The secret should contain a single key, "value", which maps to the actual Flask secret key. - To create the secret, run the following command: + This configuration is similar to `flask-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual Flask secret key. + To create the secret, run the following command: `juju add-secret my-flask-secret-key value= && juju grant-secret my-flask-secret-key flask-k8s`, and use the outputted secret ID to configure this option. type: secret @@ -82,6 +82,9 @@ config: webserver-workers: description: The number of webserver worker processes for handling requests. type: int + webserver-worker-class: + description: The method of webserver worker processes for handling requests. Can be either 'gevent' or 'sync'. + type: string secret-test: description: A test configuration option for testing user provided Juju secrets. type: secret diff --git a/examples/flask/test_async_rock/app.py b/examples/flask/test_async_rock/app.py new file mode 100644 index 0000000..ec6131a --- /dev/null +++ b/examples/flask/test_async_rock/app.py @@ -0,0 +1,23 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +import os +import socket +import time + +from flask import Flask, g, jsonify, request + +app = Flask(__name__) + + +@app.route("/") +def hello_world(): + return "Hello, World!" + + +@app.route("/sleep") +def sleep(): + duration_seconds = int(request.args.get("duration")) + time.sleep(duration_seconds) + return "" diff --git a/examples/flask/test_async_rock/requirements.txt b/examples/flask/test_async_rock/requirements.txt new file mode 100644 index 0000000..ea9f5b2 --- /dev/null +++ b/examples/flask/test_async_rock/requirements.txt @@ -0,0 +1,2 @@ +Flask +gevent diff --git a/examples/flask/test_async_rock/rockcraft.yaml b/examples/flask/test_async_rock/rockcraft.yaml new file mode 100644 index 0000000..9c789eb --- /dev/null +++ b/examples/flask/test_async_rock/rockcraft.yaml @@ -0,0 +1,13 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +name: test-async-flask +summary: A flask async worker test app +description: OCI image for the async worker test flask app +version: "0.1" +base: ubuntu@22.04 +license: Apache-2.0 +platforms: + amd64: + +extensions: + - flask-framework diff --git a/src/paas_charm/_gunicorn/charm.py b/src/paas_charm/_gunicorn/charm.py index 0273687..33c7e4e 100644 --- a/src/paas_charm/_gunicorn/charm.py +++ b/src/paas_charm/_gunicorn/charm.py @@ -2,13 +2,17 @@ # See LICENSE file for licensing details. """The base charm class for all charms.""" + import logging -from paas_charm._gunicorn.webserver import GunicornWebserver, WebserverConfig +from ops.pebble import ExecError, ExecProcess + +from paas_charm._gunicorn.webserver import GunicornWebserver, WebserverConfig, WorkerClassEnum from paas_charm._gunicorn.workload_config import create_workload_config from paas_charm._gunicorn.wsgi_app import WsgiApp from paas_charm.app import App, WorkloadConfig from paas_charm.charm import PaasCharm +from paas_charm.exceptions import CharmConfigInvalidError logger = logging.getLogger(__name__) @@ -23,6 +27,48 @@ def _workload_config(self) -> WorkloadConfig: framework_name=self._framework_name, unit_name=self.unit.name ) + def create_webserver_config(self) -> WebserverConfig: + """Validate worker_class and create a WebserverConfig instance from the charm config. + + Returns: + A validated WebserverConfig instance. + + Raises: + CharmConfigInvalidError: if the charm configuration is not valid. + """ + webserver_config: WebserverConfig = WebserverConfig.from_charm_config(dict(self.config)) + if not webserver_config.worker_class: + return webserver_config + + doc_link = f"https://bit.ly/{self._framework_name}-async-doc" + + worker_class = WorkerClassEnum.SYNC + try: + worker_class = WorkerClassEnum(webserver_config.worker_class) + except ValueError as exc: + logger.error( + "Only 'gevent' and 'sync' are allowed. %s", + doc_link, + ) + raise CharmConfigInvalidError( + f"Only 'gevent' and 'sync' are allowed. {doc_link}" + ) from exc + + # If the worker_class = sync is the default. + if worker_class is WorkerClassEnum.SYNC: + return webserver_config + + if not self._check_gevent_package(): + logger.error( + "gunicorn[gevent] must be installed in the rock. %s", + doc_link, + ) + raise CharmConfigInvalidError( + f"gunicorn[gevent] must be installed in the rock. {doc_link}" + ) + + return webserver_config + def _create_app(self) -> App: """Build an App instance for the Gunicorn based charm. @@ -32,7 +78,7 @@ def _create_app(self) -> App: charm_state = self._create_charm_state() webserver = GunicornWebserver( - webserver_config=WebserverConfig.from_charm_config(dict(self.config)), + webserver_config=self.create_webserver_config(), workload_config=self._workload_config, container=self.unit.get_container(self._workload_config.container_name), ) @@ -44,3 +90,19 @@ def _create_app(self) -> App: webserver=webserver, database_migration=self._database_migration, ) + + def _check_gevent_package(self) -> bool: + """Check that gevent is installed. + + Returns: + True if gevent is installed. + """ + try: + check_gevent_process: ExecProcess = self._container.exec( + ["python3", "-c", "import gevent"] + ) + check_gevent_process.wait_output() + return True + except ExecError as cmd_error: + logger.warning("gunicorn[gevent] install check failed: %s", cmd_error) + return False diff --git a/src/paas_charm/_gunicorn/webserver.py b/src/paas_charm/_gunicorn/webserver.py index 854ed72..7dd4512 100644 --- a/src/paas_charm/_gunicorn/webserver.py +++ b/src/paas_charm/_gunicorn/webserver.py @@ -10,6 +10,7 @@ import signal import textwrap import typing +from enum import Enum import ops from ops.pebble import ExecError, PathError @@ -26,12 +27,29 @@ logger = logging.getLogger(__name__) +class WorkerClassEnum(str, Enum): + """Enumeration class defining async modes. + + Attributes: + SYNC (str): String representation of worker class. + GEVENT (Enum): Enumeration representation of worker class. + + Args: + str (str): String representation of worker class. + Enum (Enum): Enumeration representation of worker class. + """ + + SYNC = "sync" + GEVENT = "gevent" + + @dataclasses.dataclass class WebserverConfig: """Represent the configuration values for a web server. Attributes: workers: The number of workers to use for the web server, or None if not specified. + worker_class: The method of workers to use for the web server, or sync if not specified. threads: The number of threads per worker to use for the web server, or None if not specified. keepalive: The time to wait for requests on a Keep-Alive connection, @@ -40,11 +58,14 @@ class WebserverConfig: """ workers: int | None = None + worker_class: WorkerClassEnum | None = WorkerClassEnum.SYNC threads: int | None = None keepalive: datetime.timedelta | None = None timeout: datetime.timedelta | None = None - def items(self) -> typing.Iterable[tuple[str, int | datetime.timedelta | None]]: + def items( + self, + ) -> typing.Iterable[tuple[str, str | WorkerClassEnum | int | datetime.timedelta | None]]: """Return the dataclass values as an iterable of the key-value pairs. Returns: @@ -52,13 +73,16 @@ def items(self) -> typing.Iterable[tuple[str, int | datetime.timedelta | None]]: """ return { "workers": self.workers, + "worker_class": self.worker_class, "threads": self.threads, "keepalive": self.keepalive, "timeout": self.timeout, }.items() @classmethod - def from_charm_config(cls, config: dict[str, int | float | str | bool]) -> "WebserverConfig": + def from_charm_config( + cls, config: dict[str, WorkerClassEnum | int | float | str | bool] + ) -> "WebserverConfig": """Create a WebserverConfig object from a charm state object. Args: @@ -70,9 +94,13 @@ def from_charm_config(cls, config: dict[str, int | float | str | bool]) -> "Webs keepalive = config.get("webserver-keepalive") timeout = config.get("webserver-timeout") workers = config.get("webserver-workers") + worker_class = config.get("webserver-worker-class") threads = config.get("webserver-threads") return cls( workers=int(typing.cast(str, workers)) if workers is not None else None, + worker_class=( + typing.cast(WorkerClassEnum, worker_class) if worker_class is not None else None + ), threads=int(typing.cast(str, threads)) if threads is not None else None, keepalive=( datetime.timedelta(seconds=int(keepalive)) if keepalive is not None else None @@ -111,12 +139,16 @@ def _config(self) -> str: """ config_entries = [] for setting, setting_value in self._webserver_config.items(): - setting_value = typing.cast(None | int | datetime.timedelta, setting_value) + setting_value = typing.cast( + None | str | WorkerClassEnum | int | datetime.timedelta, setting_value + ) + if setting == "worker_class": + continue if setting_value is None: continue setting_value = ( setting_value - if isinstance(setting_value, int) + if isinstance(setting_value, (int, str)) else int(setting_value.total_seconds()) ) config_entries.append(f"{setting} = {setting_value}") @@ -173,7 +205,7 @@ def update_config( self._container.push(webserver_config_path, self._config) if current_webserver_config == self._config: return - check_config_command = shlex.split(command) + check_config_command = shlex.split(command.split("-k")[0]) check_config_command.append("--check-config") exec_process = self._container.exec( check_config_command, diff --git a/src/paas_charm/_gunicorn/wsgi_app.py b/src/paas_charm/_gunicorn/wsgi_app.py index e5a5a20..f6e3e20 100644 --- a/src/paas_charm/_gunicorn/wsgi_app.py +++ b/src/paas_charm/_gunicorn/wsgi_app.py @@ -45,6 +45,13 @@ def __init__( # pylint: disable=too-many-arguments framework_config_prefix=f"{workload_config.framework.upper()}_", ) self._webserver = webserver + current_command = self._app_layer()["services"][self._workload_config.framework][ + "command" + ].split("-k")[0] + new_command = f"{current_command} -k sync" + if webserver._webserver_config.worker_class: + new_command = f"{current_command} -k {webserver._webserver_config.worker_class}" + self._alternate_service_command = new_command def _prepare_service_for_restart(self) -> None: """Specific framework operations before restarting the service.""" diff --git a/src/paas_charm/app.py b/src/paas_charm/app.py index 728abfc..df25dd5 100644 --- a/src/paas_charm/app.py +++ b/src/paas_charm/app.py @@ -67,7 +67,9 @@ def should_run_scheduler(self) -> bool: return unit_id == "0" -class App: +# too-many-instance-attributes is disabled because this class +# contains 1 more attributes than pylint allows +class App: # pylint: disable=too-many-instance-attributes """Base class for the application manager.""" def __init__( # pylint: disable=too-many-arguments @@ -92,6 +94,7 @@ def __init__( # pylint: disable=too-many-arguments configuration_prefix: prefix for environment variables related to configuration. integrations_prefix: prefix for environment variables related to integrations. """ + self.__alternate_service_command: str | None = None self._container = container self._charm_state = charm_state self._workload_config = workload_config @@ -172,6 +175,16 @@ def gen_environment(self) -> dict[str, str]: ) return env + @property + def _alternate_service_command(self) -> str | None: + """Specific framework operations before starting the service.""" + return self.__alternate_service_command + + @_alternate_service_command.setter + def _alternate_service_command(self, value: str | None) -> None: + """Specific framework operations before starting the service.""" + self.__alternate_service_command = value + def _prepare_service_for_restart(self) -> None: """Specific framework operations before restarting the service.""" @@ -213,6 +226,10 @@ def _app_layer(self) -> ops.pebble.LayerDict: services[self._workload_config.service_name]["override"] = "replace" services[self._workload_config.service_name]["environment"] = self.gen_environment() + if self._alternate_service_command: + services[self._workload_config.service_name][ + "command" + ] = self._alternate_service_command for service_name, service in services.items(): normalised_service_name = service_name.lower() diff --git a/tests/conftest.py b/tests/conftest.py index c6002bb..974a0a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,10 @@ def pytest_addoption(parser): """Define some command line options for integration and unit tests.""" parser.addoption("--charm-file", action="extend", nargs="+", default=[]) parser.addoption("--test-flask-image", action="store") + parser.addoption("--test-async-flask-image", action="store") parser.addoption("--test-db-flask-image", action="store") parser.addoption("--django-app-image", action="store") + parser.addoption("--django-async-app-image", action="store") parser.addoption("--fastapi-app-image", action="store") parser.addoption("--go-app-image", action="store") parser.addoption("--localstack-address", action="store") diff --git a/tests/integration/django/conftest.py b/tests/integration/django/conftest.py index b93c3e8..3f01742 100644 --- a/tests/integration/django/conftest.py +++ b/tests/integration/django/conftest.py @@ -25,13 +25,22 @@ def cwd(): @pytest.fixture(scope="module", name="django_app_image") def fixture_django_app_image(pytestconfig: Config): - """Return the --flask-app-image test parameter.""" + """Return the --django-app-image test parameter.""" image = pytestconfig.getoption("--django-app-image") if not image: raise ValueError("the following arguments are required: --django-app-image") return image +@pytest.fixture(scope="module", name="django_async_app_image") +def fixture_django_async_app_image(pytestconfig: Config): + """Return the --django-async-app-image test parameter.""" + image = pytestconfig.getoption("--django-async-app-image") + if not image: + raise ValueError("the following arguments are required: --django-async-app-image") + return image + + @pytest_asyncio.fixture(scope="module", name="charm_file") async def charm_file_fixture( pytestconfig: pytest.Config, ops_test: OpsTest, tmp_path_factory @@ -49,6 +58,7 @@ async def charm_file_fixture( charm_file, { "allowed-hosts": {"type": "string"}, + "webserver-worker-class": {"type": "string"}, }, tmp_path_factory.mktemp("django"), ) @@ -74,6 +84,28 @@ async def django_app_fixture(charm_file: str, model: Model, django_app_image: st return app +@pytest_asyncio.fixture(scope="module", name="django_async_app") +async def django_async_app_fixture( + charm_file: str, model: Model, django_async_app_image: str, postgresql_k8s +): + """Build and deploy the async django charm.""" + app_name = "django-async-k8s" + + resources = { + "django-app-image": django_async_app_image, + } + app = await model.deploy( + charm_file, + application_name=app_name, + config={"django-allowed-hosts": "*"}, + resources=resources, + series="jammy", + ) + await model.integrate(app_name, "postgresql-k8s") + await model.wait_for_idle(status="active") + return app + + @pytest_asyncio.fixture async def update_config(model: Model, request: FixtureRequest, django_app: Application): """Update the django application configuration. diff --git a/tests/integration/django/test_workers.py b/tests/integration/django/test_workers.py new file mode 100644 index 0000000..5f82bb3 --- /dev/null +++ b/tests/integration/django/test_workers.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for Django charm.""" +import asyncio +import logging +import time +import typing +from datetime import datetime + +import aiohttp +import pytest +import requests +from juju.application import Application +from juju.model import Model +from juju.utils import block_until +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + + +@pytest.mark.usefixtures("django_async_app") +async def test_async_workers( + ops_test: OpsTest, + model: Model, + django_async_app: Application, + get_unit_ips, +): + """ + arrange: Django is deployed with async enabled rock. Change gunicorn worker class. + act: Do 15 requests that would take 2 seconds each. + assert: All 15 requests should be served in under 3 seconds. + """ + await django_async_app.set_config({"webserver-worker-class": "gevent"}) + await model.wait_for_idle(apps=[django_async_app.name], status="active", timeout=60) + + # the django unit is not important. Take the first one + django_unit_ip = (await get_unit_ips(django_async_app.name))[0] + + async def _fetch_page(session): + params = {"duration": 2} + async with session.get(f"http://{django_unit_ip}:8000/sleep", params=params) as response: + return await response.text() + + start_time = datetime.now() + async with aiohttp.ClientSession() as session: + pages = [_fetch_page(session) for _ in range(15)] + await asyncio.gather(*pages) + assert ( + datetime.now() - start_time + ).seconds < 3, "Async workers for Django are not working!" diff --git a/tests/integration/flask/conftest.py b/tests/integration/flask/conftest.py index 6a0280c..1e40a63 100644 --- a/tests/integration/flask/conftest.py +++ b/tests/integration/flask/conftest.py @@ -26,6 +26,15 @@ def cwd(): return os.chdir(PROJECT_ROOT / "examples/flask") +@pytest.fixture(scope="module", name="test_async_flask_image") +def fixture_test_async_flask_image(pytestconfig: Config): + """Return the --test-async-flask-image test parameter.""" + test_flask_image = pytestconfig.getoption("--test-async-flask-image") + if not test_flask_image: + raise ValueError("the following arguments are required: --test-async-flask-image") + return test_flask_image + + @pytest.fixture(scope="module", name="test_flask_image") def fixture_test_flask_image(pytestconfig: Config): """Return the --test-flask-image test parameter.""" @@ -75,6 +84,7 @@ async def build_charm_fixture(charm_file: str, tmp_path_factory) -> str: "foo-bool": {"type": "boolean"}, "foo-dict": {"type": "string"}, "application-root": {"type": "string"}, + "webserver-worker-class": {"type": "string"}, }, tmp_path_factory.mktemp("flask"), ) @@ -110,6 +120,21 @@ async def flask_db_app_fixture(build_charm: str, model: Model, test_db_flask_ima return app +@pytest_asyncio.fixture(scope="module", name="flask_async_app") +async def flask_async_app_fixture(build_charm: str, model: Model, test_async_flask_image: str): + """Build and deploy the flask charm with test-async-flask image.""" + app_name = "flask-async-k8s" + + resources = { + "flask-app-image": test_async_flask_image, + } + app = await model.deploy( + build_charm, resources=resources, application_name=app_name, series="jammy" + ) + await model.wait_for_idle(raise_on_blocked=True) + return app + + @pytest_asyncio.fixture(scope="module", name="traefik_app") async def deploy_traefik_fixture( model: Model, diff --git a/tests/integration/flask/requirements.txt b/tests/integration/flask/requirements.txt index 6ec779d..13ce280 100644 --- a/tests/integration/flask/requirements.txt +++ b/tests/integration/flask/requirements.txt @@ -1,2 +1,3 @@ ops >= 1.5.0 pytest-operator >= 0.32.0 +aiohttp == 3.11.7 diff --git a/tests/integration/flask/test_workers.py b/tests/integration/flask/test_workers.py index 9dea9fd..323992c 100644 --- a/tests/integration/flask/test_workers.py +++ b/tests/integration/flask/test_workers.py @@ -6,7 +6,9 @@ import asyncio import logging import time +from datetime import datetime +import aiohttp import pytest import requests from juju.application import Application @@ -68,3 +70,35 @@ def check_correct_celery_stats(num_schedulers, num_workers): ) except asyncio.TimeoutError: assert False, "Failed to get 2 workers and 1 scheduler" + + +@pytest.mark.usefixtures("flask_async_app") +async def test_async_workers( + ops_test: OpsTest, + model: Model, + flask_async_app: Application, + get_unit_ips, +): + """ + arrange: Flask is deployed with async enabled rock. Change gunicorn worker class. + act: Do 15 requests that would take 2 seconds each. + assert: All 15 requests should be served in under 3 seconds. + """ + await flask_async_app.set_config({"webserver-worker-class": "gevent"}) + await model.wait_for_idle(apps=[flask_async_app.name], status="active", timeout=60) + + # the flask unit is not important. Take the first one + flask_unit_ip = (await get_unit_ips(flask_async_app.name))[0] + + async def _fetch_page(session): + params = {"duration": 2} + async with session.get(f"http://{flask_unit_ip}:8000/sleep", params=params) as response: + return await response.text() + + start_time = datetime.now() + async with aiohttp.ClientSession() as session: + pages = [_fetch_page(session) for _ in range(15)] + await asyncio.gather(*pages) + assert ( + datetime.now() - start_time + ).seconds < 3, "Async workers for Flask are not working!" diff --git a/tests/unit/django/conftest.py b/tests/unit/django/conftest.py index b5e1c3e..9fbb487 100644 --- a/tests/unit/django/conftest.py +++ b/tests/unit/django/conftest.py @@ -87,6 +87,16 @@ def database_migration_mock(): return mock +@pytest.fixture +def container_mock(): + """Create a mock instance for the Container.""" + mock = unittest.mock.MagicMock() + pull_result = unittest.mock.MagicMock() + pull_result.read.return_value = str(DEFAULT_LAYER["services"]).replace("'", '"') + mock.pull.return_value = pull_result + return mock + + def _build_harness(meta=None): """Create a harness instance with the specified metadata.""" harness = Harness(DjangoCharm, meta=meta) @@ -104,7 +114,7 @@ def check_config_handler(_): return ops.testing.ExecResult(1) check_config_command = [ - *shlex.split(DEFAULT_LAYER["services"]["django"]["command"]), + *shlex.split(DEFAULT_LAYER["services"]["django"]["command"].split("-k")[0]), "--check-config", ] harness.handle_exec( diff --git a/tests/unit/django/constants.py b/tests/unit/django/constants.py index 3145cf1..4829ade 100644 --- a/tests/unit/django/constants.py +++ b/tests/unit/django/constants.py @@ -6,7 +6,7 @@ "django": { "override": "replace", "startup": "enabled", - "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py django_app.wsgi:application", + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py django_app.wsgi:application -k sync", "after": ["statsd-exporter"], "user": "_daemon_", }, diff --git a/tests/unit/django/test_charm.py b/tests/unit/django/test_charm.py index 8d66364..88554ed 100644 --- a/tests/unit/django/test_charm.py +++ b/tests/unit/django/test_charm.py @@ -94,7 +94,7 @@ def test_django_config(harness: Harness, config: dict, env: dict) -> None: "environment": env, "override": "replace", "startup": "enabled", - "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py django_app.wsgi:application", + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py django_app.wsgi:application -k sync", "after": ["statsd-exporter"], "user": "_daemon_", } @@ -155,6 +155,56 @@ def test_required_database_integration(harness_no_integrations: Harness): ) +@pytest.mark.parametrize("config, env", TEST_DJANGO_CONFIG_PARAMS) +def test_django_async_config(harness: Harness, config: dict, env: dict) -> None: + """ + arrange: None + act: Start the django charm and set django-app container to be ready. + assert: Django charm should submit the correct pebble layer to pebble. + """ + harness.begin() + container = harness.charm.unit.get_container("django-app") + # ops.testing framework apply layers by label in lexicographical order... + container.add_layer("a_layer", DEFAULT_LAYER) + secret_storage = unittest.mock.MagicMock() + secret_storage.is_secret_storage_ready = True + secret_storage.get_secret_key.return_value = "test" + config["webserver-worker-class"] = "gevent" + harness.update_config(config) + charm_state = CharmState.from_charm( + config=harness.charm.config, + framework="django", + framework_config=harness.charm.get_framework_config(), + secret_storage=secret_storage, + database_requirers={}, + ) + webserver_config = WebserverConfig.from_charm_config(harness.charm.config) + workload_config = create_workload_config(framework_name="django", unit_name="django/0") + webserver = GunicornWebserver( + webserver_config=webserver_config, + workload_config=workload_config, + container=container, + ) + django_app = WsgiApp( + container=harness.charm.unit.get_container("django-app"), + charm_state=charm_state, + workload_config=workload_config, + webserver=webserver, + database_migration=harness.charm._database_migration, + ) + django_app.restart() + plan = container.get_plan() + django_layer = plan.to_dict()["services"]["django"] + assert django_layer == { + "environment": env, + "override": "replace", + "startup": "enabled", + "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py django_app.wsgi:application -k gevent", + "after": ["statsd-exporter"], + "user": "_daemon_", + } + + def test_allowed_hosts_base_hostname_updates_correctly(harness: Harness): """ arrange: Deploy a Django charm without an ingress integration diff --git a/tests/unit/django/test_workers.py b/tests/unit/django/test_workers.py new file mode 100644 index 0000000..e4c605b --- /dev/null +++ b/tests/unit/django/test_workers.py @@ -0,0 +1,103 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for worker services.""" + +import ops +import pytest +from ops.testing import ExecResult, Harness + +from .constants import DEFAULT_LAYER + + +@pytest.mark.parametrize( + "worker_class, expected_status, expected_message, exec_res", + [ + ( + "eventlet", + "blocked", + "Only 'gevent' and 'sync' are allowed. https://bit.ly/django-async-doc", + 1, + ), + ("gevent", "active", "", 0), + ("sync", "active", "", 0), + ], +) +def test_async_workers_config( + harness: Harness, worker_class, expected_status, expected_message, exec_res +): + """ + arrange: Prepare a unit and run initial hooks. + act: Set the `webserver-worker-class` config. + assert: The charm should be blocked if the `webserver-worker-class` config is anything other + then `sync` or `gevent`. + """ + postgresql_relation_data = { + "database": "test-database", + "endpoints": "test-postgresql:5432,test-postgresql-2:5432", + "password": "test-password", + "username": "test-username", + } + harness.add_relation("postgresql", "postgresql-k8s", app_data=postgresql_relation_data) + container = harness.model.unit.get_container("django-app") + container.add_layer("a_layer", DEFAULT_LAYER) + + harness.handle_exec( + container.name, + ["python3", "-c", "import gevent"], + result=ExecResult(exit_code=exec_res), + ) + + harness.begin_with_initial_hooks() + harness.update_config({"webserver-worker-class": worker_class}) + assert harness.model.unit.status == ops.StatusBase.from_name( + name=expected_status, message=expected_message + ) + + +@pytest.mark.parametrize( + "worker_class, expected_status, expected_message, exec_res", + [ + ( + "eventlet", + "blocked", + "Only 'gevent' and 'sync' are allowed. https://bit.ly/django-async-doc", + 1, + ), + ( + "gevent", + "blocked", + "gunicorn[gevent] must be installed in the rock. https://bit.ly/django-async-doc", + 1, + ), + ("sync", "active", "", 0), + ], +) +def test_async_workers_config_fail( + harness: Harness, worker_class, expected_status, expected_message, exec_res +): + """ + arrange: Prepare a unit and run initial hooks. + act: Set the `webserver-worker-class` config. + assert: The charm should be blocked if the `webserver-worker-class` config is anything other + then `sync`. + """ + postgresql_relation_data = { + "database": "test-database", + "endpoints": "test-postgresql:5432,test-postgresql-2:5432", + "password": "test-password", + "username": "test-username", + } + harness.add_relation("postgresql", "postgresql-k8s", app_data=postgresql_relation_data) + container = harness.model.unit.get_container("django-app") + container.add_layer("a_layer", DEFAULT_LAYER) + harness.handle_exec( + container.name, + ["python3", "-c", "import gevent"], + result=ExecResult(exit_code=exec_res), + ) + harness.begin_with_initial_hooks() + harness.update_config({"webserver-worker-class": worker_class}) + assert harness.model.unit.status == ops.StatusBase.from_name( + name=expected_status, message=expected_message + ) diff --git a/tests/unit/flask/conftest.py b/tests/unit/flask/conftest.py index 604d22f..cd4d210 100644 --- a/tests/unit/flask/conftest.py +++ b/tests/unit/flask/conftest.py @@ -42,7 +42,7 @@ def check_config_handler(_): return ops.testing.ExecResult(1) check_config_command = [ - *shlex.split(DEFAULT_LAYER["services"]["flask"]["command"]), + *shlex.split(DEFAULT_LAYER["services"]["flask"]["command"].split("-k")[0]), "--check-config", ] harness.handle_exec( @@ -62,3 +62,13 @@ def database_migration_mock(): mock.status = DatabaseMigrationStatus.PENDING mock.script = None return mock + + +@pytest.fixture +def container_mock(): + """Create a mock instance for the Container.""" + mock = unittest.mock.MagicMock() + pull_result = unittest.mock.MagicMock() + pull_result.read.return_value = str(DEFAULT_LAYER["services"]).replace("'", '"') + mock.pull.return_value = pull_result + return mock diff --git a/tests/unit/flask/constants.py b/tests/unit/flask/constants.py index d9936c3..68756a6 100644 --- a/tests/unit/flask/constants.py +++ b/tests/unit/flask/constants.py @@ -8,7 +8,7 @@ "flask": { "override": "replace", "startup": "enabled", - "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app -k sync", "after": ["statsd-exporter"], "user": "_daemon_", }, @@ -31,7 +31,7 @@ "flask": { "override": "replace", "startup": "enabled", - "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app -k sync", "after": ["statsd-exporter"], "user": "_daemon_", }, diff --git a/tests/unit/flask/test_charm.py b/tests/unit/flask/test_charm.py index 4e057e9..5acf5cf 100644 --- a/tests/unit/flask/test_charm.py +++ b/tests/unit/flask/test_charm.py @@ -74,7 +74,7 @@ def test_flask_pebble_layer(harness: Harness) -> None: }, "override": "replace", "startup": "enabled", - "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + "command": f"/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app -k sync", "after": ["statsd-exporter"], "user": "_daemon_", } diff --git a/tests/unit/flask/test_flask_app.py b/tests/unit/flask/test_flask_app.py index 596381c..a647a48 100644 --- a/tests/unit/flask/test_flask_app.py +++ b/tests/unit/flask/test_flask_app.py @@ -33,7 +33,7 @@ ), ], ) -def test_flask_env(flask_config: dict, app_config: dict, database_migration_mock): +def test_flask_env(flask_config: dict, app_config: dict, database_migration_mock, container_mock): """ arrange: create the Flask app object with a controlled charm state. act: none. @@ -48,7 +48,7 @@ def test_flask_env(flask_config: dict, app_config: dict, database_migration_mock ) workload_config = create_workload_config(framework_name="flask", unit_name="flask/0") flask_app = WsgiApp( - container=unittest.mock.MagicMock(), + container=container_mock, charm_state=charm_state, workload_config=workload_config, webserver=unittest.mock.MagicMock(), @@ -110,6 +110,7 @@ def test_http_proxy( expected: typing.Dict[str, str], monkeypatch, database_migration_mock, + container_mock, ): """ arrange: set juju charm http proxy related environment variables. @@ -125,7 +126,7 @@ def test_http_proxy( ) workload_config = create_workload_config(framework_name="flask", unit_name="flask/0") flask_app = WsgiApp( - container=unittest.mock.MagicMock(), + container=container_mock, charm_state=charm_state, workload_config=workload_config, webserver=unittest.mock.MagicMock(), @@ -162,6 +163,7 @@ def test_http_proxy( def test_integrations_env( monkeypatch, database_migration_mock, + container_mock, integrations, expected_vars, ): @@ -178,7 +180,7 @@ def test_integrations_env( ) workload_config = create_workload_config(framework_name="flask", unit_name="flask/0") flask_app = WsgiApp( - container=unittest.mock.MagicMock(), + container=container_mock, charm_state=charm_state, workload_config=workload_config, webserver=unittest.mock.MagicMock(), diff --git a/tests/unit/flask/test_workers.py b/tests/unit/flask/test_workers.py index 144dc8f..9e0d077 100644 --- a/tests/unit/flask/test_workers.py +++ b/tests/unit/flask/test_workers.py @@ -9,9 +9,9 @@ import ops import pytest -from ops.testing import Harness +from ops.testing import ExecResult, Harness -from .constants import FLASK_CONTAINER_NAME, LAYER_WITH_WORKER +from .constants import DEFAULT_LAYER, FLASK_CONTAINER_NAME, LAYER_WITH_WORKER def test_worker(harness: Harness): @@ -39,6 +39,86 @@ def test_worker(harness: Harness): assert "FLASK_SECRET_KEY" not in services["not-worker-service"].environment +@pytest.mark.parametrize( + "worker_class, expected_status, expected_message, exec_res", + [ + ( + "eventlet", + "blocked", + "Only 'gevent' and 'sync' are allowed. https://bit.ly/flask-async-doc", + 1, + ), + ("gevent", "active", "", 0), + ("sync", "active", "", 0), + ], +) +def test_async_workers_config( + harness: Harness, worker_class, expected_status, expected_message, exec_res +): + """ + arrange: Prepare a unit and run initial hooks. + act: Set the `webserver-worker-class` config. + assert: The charm should be blocked if the `webserver-worker-class` config is anything other + then `sync` or `gevent`. + """ + container = harness.model.unit.get_container(FLASK_CONTAINER_NAME) + container.add_layer("a_layer", DEFAULT_LAYER) + + harness.handle_exec( + container.name, + ["python3", "-c", "import gevent"], + result=ExecResult(exit_code=exec_res), + ) + + harness.begin_with_initial_hooks() + harness.update_config({"webserver-worker-class": worker_class}) + assert harness.model.unit.status == ops.StatusBase.from_name( + name=expected_status, message=expected_message + ) + + +@pytest.mark.parametrize( + "worker_class, expected_status, expected_message, exec_res", + [ + ( + "eventlet", + "blocked", + "Only 'gevent' and 'sync' are allowed. https://bit.ly/flask-async-doc", + 1, + ), + ( + "gevent", + "blocked", + "gunicorn[gevent] must be installed in the rock. https://bit.ly/flask-async-doc", + 1, + ), + ("sync", "active", "", 0), + ], +) +def test_async_workers_config_fail( + harness: Harness, worker_class, expected_status, expected_message, exec_res +): + """ + arrange: Prepare a unit and run initial hooks. + act: Set the `webserver-worker-class` config. + assert: The charm should be blocked if the `webserver-worker-class` config is anything other + then `sync`. + """ + container = harness.model.unit.get_container(FLASK_CONTAINER_NAME) + container.add_layer("a_layer", DEFAULT_LAYER) + + harness.handle_exec( + container.name, + ["python3", "-c", "import gevent"], + result=ExecResult(exit_code=exec_res), + ) + harness.begin_with_initial_hooks() + harness.update_config({"webserver-worker-class": worker_class}) + assert harness.model.unit.status == ops.StatusBase.from_name( + name=expected_status, message=expected_message + ) + + def test_worker_multiple_units(harness: Harness): """ arrange: Prepare a unit with workers that is not the first one (number 1)