Tussilago/control_plane/local_test_runtime.py
2026-04-27 20:43:26 +02:00

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()