from __future__ import annotations from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING from django.conf import settings from control_plane.models import RuntimeServiceKind from control_plane.models import _build_limited_identifier if TYPE_CHECKING: from collections.abc import Iterable from control_plane.models import Deployment from control_plane.models import RuntimeService TEST_DJANGO_CONTAINER_PORT = 8000 TEST_DJANGO_IMAGE_REFERENCE = "localhost/tussilago-test-django:latest" TEST_DJANGO_WORKDIR = "/srv/test-app" TEST_POSTGRES_AUTH_DIR = "/run/postgres-auth" TEST_REDIS_AUTH_DIR = "/run/redis-auth" TEST_POSTGRES_PASSWORD_FILE = f"{TEST_POSTGRES_AUTH_DIR}/password" TEST_REDIS_PASSWORD_FILE = f"{TEST_REDIS_AUTH_DIR}/password" def build_test_django_project_root(deployment: Deployment) -> Path: """Return filesystem root for one generated local Django test app.""" return Path(settings.DATA_DIR) / "test-deployments" / str(deployment.id) / "django-app" def build_test_django_image_reference() -> str: """Return Podman image reference for the reusable local Django runtime.""" return TEST_DJANGO_IMAGE_REFERENCE def build_test_django_containerfile_path() -> Path: """Return checked-in Containerfile used for local Django test runtimes.""" return Path(__file__).resolve().parent / "container_assets" / "test_django" / "Containerfile" def build_test_django_container_context_path() -> Path: """Return Podman build context for the reusable local Django runtime image.""" return build_test_django_containerfile_path().parent def build_test_django_local_url(deployment: Deployment) -> str: """Return published sentinel URL for a local Django test deployment.""" return f"http://127.0.0.1:{deployment.guest_port}/sentinel/" def build_test_django_container_names(deployment: Deployment) -> tuple[str, str]: """Return deterministic Podman container names for server and migrate steps.""" deployment_suffix = deployment.id.hex[:12] tenant_slug = deployment.hosted_site.tenant.slug site_slug = deployment.hosted_site.slug return ( _build_limited_identifier( prefix="django", tenant_slug=tenant_slug, site_slug=site_slug, suffix=deployment_suffix, max_length=128, ), _build_limited_identifier( prefix="django-migrate", tenant_slug=tenant_slug, site_slug=site_slug, suffix=deployment_suffix, max_length=128, ), ) def build_test_django_container_labels(deployment: Deployment) -> tuple[tuple[str, str], ...]: """Return stable labels to simplify inspection and cleanup.""" return ( ("tussilago.deployment-id", str(deployment.id)), ("tussilago.tenant-slug", deployment.hosted_site.tenant.slug), ("tussilago.site-slug", deployment.hosted_site.slug), ("tussilago.role", "django"), ) def build_test_django_environment( deployment: Deployment, runtime_services: Iterable[RuntimeService], ) -> tuple[tuple[str, str], ...]: """Return container environment variables for the generated Django test app. Raises: ValueError: If PostgreSQL or Redis runtime services are missing. """ postgres_service = _get_runtime_service(runtime_services, RuntimeServiceKind.POSTGRESQL.value) redis_service = _get_runtime_service(runtime_services, RuntimeServiceKind.REDIS.value) if not postgres_service.connection_database or not postgres_service.connection_username: msg = "PostgreSQL runtime service is missing connection credentials." raise ValueError(msg) return ( ("DJANGO_SECRET_KEY", f"test-deployment-{deployment.id.hex}"), ("DJANGO_SETTINGS_MODULE", "tenant_site.settings"), ("PYTHONPATH", TEST_DJANGO_WORKDIR), ("TEST_TENANT_SLUG", deployment.hosted_site.tenant.slug), ("TEST_SITE_SLUG", deployment.hosted_site.slug), ("TEST_POSTGRES_HOST", "127.0.0.1"), ("TEST_POSTGRES_PORT", str(postgres_service.internal_port)), ("TEST_POSTGRES_DATABASE", postgres_service.connection_database), ("TEST_POSTGRES_USERNAME", postgres_service.connection_username), ("TEST_POSTGRES_PASSWORD_FILE", TEST_POSTGRES_PASSWORD_FILE), ("TEST_REDIS_HOST", "127.0.0.1"), ("TEST_REDIS_PORT", str(redis_service.internal_port)), ("TEST_REDIS_PASSWORD_FILE", TEST_REDIS_PASSWORD_FILE), ) def build_test_django_secret_mounts( runtime_services: Iterable[RuntimeService], ) -> tuple[tuple[Path, str], ...]: """Return host-to-container secret mounts for generated Django test apps.""" postgres_service = _get_runtime_service(runtime_services, RuntimeServiceKind.POSTGRESQL.value) redis_service = _get_runtime_service(runtime_services, RuntimeServiceKind.REDIS.value) return ( (_runtime_service_secret_directory(postgres_service), TEST_POSTGRES_AUTH_DIR), (_runtime_service_secret_directory(redis_service), TEST_REDIS_AUTH_DIR), ) def write_test_django_project( deployment: Deployment, runtime_services: Iterable[RuntimeService], ) -> Path: """Write deterministic Django project files for one deployment. Returns: Root directory containing the generated Django project. """ build_test_django_environment(deployment, runtime_services) project_root = build_test_django_project_root(deployment) package_root = project_root / "tenant_site" package_root.mkdir(parents=True, exist_ok=True) (project_root / "manage.py").write_text(_manage_py_contents(), encoding="utf-8") (package_root / "__init__.py").write_text("", encoding="utf-8") (package_root / "settings.py").write_text(_settings_contents(), encoding="utf-8") (package_root / "urls.py").write_text(_urls_contents(), encoding="utf-8") (package_root / "wsgi.py").write_text(_wsgi_contents(), encoding="utf-8") return project_root def _get_runtime_service( runtime_services: Iterable[RuntimeService], kind: str, ) -> RuntimeService: for runtime_service in runtime_services: if runtime_service.kind == kind: return runtime_service msg = f"Missing runtime service kind: {kind}" raise ValueError(msg) def _runtime_service_secret_directory(runtime_service: RuntimeService) -> Path: return ( Path(settings.DATA_DIR) / "runtime-services" / str(runtime_service.deployment_id) / runtime_service.kind / "secrets" ) def _manage_py_contents() -> str: return dedent( """ #!/usr/bin/env python import os import sys def main() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_site.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) if __name__ == "__main__": main() """, ).lstrip() def _settings_contents() -> str: return dedent( """ import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] DEBUG = False ALLOWED_HOSTS = ["127.0.0.1", "localhost"] ROOT_URLCONF = "tenant_site.urls" WSGI_APPLICATION = "tenant_site.wsgi.application" INSTALLED_APPS = [ "django.contrib.contenttypes", ] MIDDLEWARE = [] TIME_ZONE = "UTC" USE_TZ = True DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" TEST_TENANT_SLUG = os.environ["TEST_TENANT_SLUG"] TEST_SITE_SLUG = os.environ["TEST_SITE_SLUG"] TEST_REDIS_HOST = os.environ["TEST_REDIS_HOST"] TEST_REDIS_PORT = int(os.environ["TEST_REDIS_PORT"]) def _read_secret(env_key: str) -> str: return Path(os.environ[env_key]).read_text(encoding="utf-8").strip() DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["TEST_POSTGRES_DATABASE"], "USER": os.environ["TEST_POSTGRES_USERNAME"], "PASSWORD": _read_secret("TEST_POSTGRES_PASSWORD_FILE"), "HOST": os.environ["TEST_POSTGRES_HOST"], "PORT": int(os.environ["TEST_POSTGRES_PORT"]), }, } TEST_REDIS_PASSWORD = _read_secret("TEST_REDIS_PASSWORD_FILE") """, ).lstrip() def _urls_contents() -> str: return dedent( """ import redis from django.conf import settings from django.db import connection from django.http import JsonResponse from django.urls import path def sentinel_view(request): with connection.cursor() as cursor: cursor.execute("SELECT 1") postgres_value = int(cursor.fetchone()[0]) redis_key = f"sentinel:{settings.TEST_TENANT_SLUG}:{settings.TEST_SITE_SLUG}" redis_client = redis.Redis( host=settings.TEST_REDIS_HOST, port=settings.TEST_REDIS_PORT, password=settings.TEST_REDIS_PASSWORD, decode_responses=True, socket_timeout=1, ) redis_client.set(redis_key, settings.TEST_SITE_SLUG, ex=60) redis_value = redis_client.get(redis_key) return JsonResponse( { "status": "ok", "postgres": postgres_value, "redis": redis_value, "tenant": settings.TEST_TENANT_SLUG, "site": settings.TEST_SITE_SLUG, }, ) urlpatterns = [ path("sentinel/", sentinel_view), ] """, ).lstrip() def _wsgi_contents() -> str: return dedent( """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tenant_site.settings") application = get_wsgi_application() """, ).lstrip()