297 lines
10 KiB
Python
297 lines
10 KiB
Python
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()
|