WIP
This commit is contained in:
parent
e70a0584c9
commit
a7a5b5c8ea
43 changed files with 5531 additions and 9 deletions
297
control_plane/local_test_runtime.py
Normal file
297
control_plane/local_test_runtime.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue