WIP
This commit is contained in:
parent
e70a0584c9
commit
a7a5b5c8ea
43 changed files with 5531 additions and 9 deletions
110
tests/test_control_plane_admin.py
Normal file
110
tests/test_control_plane_admin.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory
|
||||
|
||||
from control_plane.models import Deployment
|
||||
from control_plane.models import HostedSite
|
||||
from control_plane.models import RuntimeService
|
||||
from control_plane.models import RuntimeServiceKind
|
||||
from control_plane.models import RuntimeServiceStatus
|
||||
from control_plane.models import Tenant
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_can_view_runtime_service_changelist() -> None:
|
||||
"""Superusers should see runtime service records in Django admin."""
|
||||
user_model = get_user_model()
|
||||
admin_user = user_model.objects.create_superuser(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="admin-password",
|
||||
)
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
deployment = Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key="deploy-admin-001",
|
||||
source_sha256="c" * 64,
|
||||
)
|
||||
RuntimeService.objects.create(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.POSTGRESQL,
|
||||
status=RuntimeServiceStatus.READY,
|
||||
container_name="postgres-acme-portal",
|
||||
network_name="net-acme-portal",
|
||||
hostname="postgres.internal",
|
||||
image_reference="docker.io/library/postgres:17-alpine",
|
||||
internal_port=5432,
|
||||
connection_username="portal",
|
||||
connection_database="portal",
|
||||
connection_secret_ref="secret://postgres/acme/portal",
|
||||
)
|
||||
|
||||
request = RequestFactory().get("/admin/control_plane/runtimeservice/")
|
||||
request.user = admin_user
|
||||
model_admin = admin.site._registry[RuntimeService]
|
||||
queryset = model_admin.get_queryset(request)
|
||||
|
||||
runtime_service = queryset.get()
|
||||
|
||||
assert model_admin.has_module_permission(request) is True
|
||||
assert model_admin.has_view_permission(request) is True
|
||||
assert runtime_service.container_name == "postgres-acme-portal"
|
||||
assert model_admin.tenant_slug(runtime_service) == "acme"
|
||||
assert model_admin.site_slug(runtime_service) == "portal"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_registers_control_plane_models() -> None:
|
||||
"""Admin should expose full control-plane graph for smoke data creation."""
|
||||
assert Tenant in admin.site._registry
|
||||
assert HostedSite in admin.site._registry
|
||||
assert Deployment in admin.site._registry
|
||||
assert RuntimeService in admin.site._registry
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_admin_action_queues_test_container_provisioning() -> None:
|
||||
"""Deployment admin action should queue Celery-backed test container provisioning."""
|
||||
user_model = get_user_model()
|
||||
admin_user = user_model.objects.create_superuser(
|
||||
username="admin-action",
|
||||
email="admin-action@example.com",
|
||||
password="admin-password",
|
||||
)
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
deployment = Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key="deploy-admin-002",
|
||||
source_sha256="e" * 64,
|
||||
)
|
||||
|
||||
request = RequestFactory().post("/admin/control_plane/deployment/")
|
||||
request.user = admin_user
|
||||
model_admin = admin.site._registry[Deployment]
|
||||
deployment_queryset = Deployment.objects.filter(pk=deployment.pk)
|
||||
|
||||
with (
|
||||
patch("control_plane.admin.provision_test_runtime_services.delay") as mock_delay,
|
||||
patch.object(model_admin, "message_user") as mock_message_user,
|
||||
):
|
||||
model_admin.create_test_containers(request, deployment_queryset)
|
||||
|
||||
mock_delay.assert_called_once_with(str(deployment.id))
|
||||
mock_message_user.assert_called_once()
|
||||
205
tests/test_control_plane_dashboard.py
Normal file
205
tests/test_control_plane_dashboard.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from control_plane.models import Deployment
|
||||
from control_plane.models import DeploymentStatus
|
||||
from control_plane.models import HostedSite
|
||||
from control_plane.models import RuntimeService
|
||||
from control_plane.models import RuntimeServiceStatus
|
||||
from control_plane.models import Tenant
|
||||
from control_plane.observability import build_test_deployment_diagnostics_snapshot_path
|
||||
from control_plane.observability import capture_test_deployment_diagnostics
|
||||
from control_plane.observability import load_test_deployment_diagnostics
|
||||
from control_plane.observability import probe_test_deployment_health
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import Client
|
||||
|
||||
|
||||
def _create_deployment(*, status: str = DeploymentStatus.QUEUED.value, last_error: str = "") -> Deployment:
|
||||
tenant = Tenant.objects.create(slug=f"tenant-{status}", display_name=f"Tenant {status}")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug=f"site-{status}",
|
||||
display_name=f"Site {status}",
|
||||
wsgi_module="tenant_site.wsgi:application",
|
||||
)
|
||||
return Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key=f"deploy-{status}",
|
||||
source_sha256=status[0] * 64,
|
||||
status=status,
|
||||
last_error=last_error,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_dashboard_lists_recent_deployments(client: Client) -> None:
|
||||
"""Dashboard list view should surface deployment identity and runtime counts."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.RUNNING.value)
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(deployment=deployment).update(status=RuntimeServiceStatus.READY.value)
|
||||
|
||||
response = client.get(reverse("control_plane:deployment-dashboard"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert deployment.hosted_site.tenant.slug in content
|
||||
assert deployment.hosted_site.slug in content
|
||||
assert "Inspect deployment" in content
|
||||
assert "Open sentinel" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_detail_renders_persisted_diagnostics(tmp_path: Path, client: Client) -> None:
|
||||
"""Detail view should render captured pod state and container logs."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.RUNNING.value)
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(deployment=deployment).update(status=RuntimeServiceStatus.READY.value)
|
||||
runtime_services = tuple(RuntimeService.objects.filter(deployment=deployment).order_by("kind"))
|
||||
|
||||
with (
|
||||
override_settings(DATA_DIR=tmp_path),
|
||||
patch(
|
||||
"control_plane.views.probe_test_deployment_health",
|
||||
return_value={
|
||||
"status": "healthy",
|
||||
"label": "Healthy",
|
||||
"checked_at": "now",
|
||||
"ok": True,
|
||||
"payload": {"status": "ok"},
|
||||
"error": "",
|
||||
},
|
||||
),
|
||||
):
|
||||
snapshot_path = build_test_deployment_diagnostics_snapshot_path(deployment)
|
||||
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"captured_at": "2026-04-27T18:10:00+00:00",
|
||||
"pod": {"name": runtime_services[0].network_name, "status": "Running", "error": ""},
|
||||
"django": {
|
||||
"container_name": "django-test",
|
||||
"container_status": "running",
|
||||
"logs": "gunicorn boot ok",
|
||||
"inspect_error": "",
|
||||
"log_error": "",
|
||||
},
|
||||
"runtime_services": [
|
||||
{
|
||||
"label": runtime_services[0].kind,
|
||||
"container_name": runtime_services[0].container_name,
|
||||
"container_status": "healthy",
|
||||
"logs": "postgres boot ok",
|
||||
"inspect_error": "",
|
||||
"log_error": "",
|
||||
},
|
||||
{
|
||||
"label": runtime_services[1].kind,
|
||||
"container_name": runtime_services[1].container_name,
|
||||
"container_status": "healthy",
|
||||
"logs": "redis ready",
|
||||
"inspect_error": "",
|
||||
"log_error": "",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
response = client.get(reverse("control_plane:deployment-detail", args=[deployment.id]))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "gunicorn boot ok" in content
|
||||
assert "postgres boot ok" in content
|
||||
assert "redis ready" in content
|
||||
assert deployment.hosted_site.slug in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_health_endpoint_returns_probe_payload(client: Client) -> None:
|
||||
"""Health endpoint should proxy structured sentinel probe state as JSON."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.RUNNING.value)
|
||||
|
||||
with patch(
|
||||
"control_plane.views.probe_test_deployment_health",
|
||||
return_value={
|
||||
"checked_at": "2026-04-27T18:12:00+00:00",
|
||||
"deployment_id": str(deployment.id),
|
||||
"deployment_status": deployment.status,
|
||||
"sentinel_url": "http://127.0.0.1:9999/sentinel/",
|
||||
"ok": True,
|
||||
"status": "healthy",
|
||||
"label": "Healthy",
|
||||
"payload": {"status": "ok", "postgres": 1},
|
||||
"error": "",
|
||||
"http_status": 200,
|
||||
},
|
||||
):
|
||||
response = client.get(reverse("control_plane:deployment-health", args=[deployment.id]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
assert response.json()["payload"]["postgres"] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_capture_test_deployment_diagnostics_writes_snapshot(tmp_path: Path) -> None:
|
||||
"""Diagnostics capture should persist pod status plus log tails for each container."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.RUNNING.value)
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(deployment=deployment).update(status=RuntimeServiceStatus.READY.value)
|
||||
runtime_services = tuple(RuntimeService.objects.filter(deployment=deployment).order_by("kind"))
|
||||
|
||||
with (
|
||||
override_settings(DATA_DIR=tmp_path),
|
||||
patch(
|
||||
"control_plane.observability.run_host_command",
|
||||
side_effect=[
|
||||
MagicMock(stdout="Running\n", stderr=""),
|
||||
MagicMock(stdout="running\n", stderr=""),
|
||||
MagicMock(stdout="gunicorn started\n", stderr=""),
|
||||
MagicMock(stdout="healthy\n", stderr=""),
|
||||
MagicMock(stdout="postgres boot ok\n", stderr=""),
|
||||
MagicMock(stdout="healthy\n", stderr=""),
|
||||
MagicMock(stdout="redis ready\n", stderr=""),
|
||||
],
|
||||
),
|
||||
):
|
||||
capture_test_deployment_diagnostics(str(deployment.id))
|
||||
snapshot = load_test_deployment_diagnostics(deployment)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["pod"]["name"] == runtime_services[0].network_name
|
||||
assert snapshot["django"]["logs"] == "gunicorn started"
|
||||
assert snapshot["runtime_services"][0]["logs"] == "postgres boot ok"
|
||||
assert snapshot["runtime_services"][1]["logs"] == "redis ready"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_probe_test_deployment_health_reports_success() -> None:
|
||||
"""Sentinel probe helper should classify healthy payloads as healthy."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.RUNNING.value)
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = b'{"status": "ok", "postgres": 1, "redis": "site-running"}'
|
||||
response.status = 200
|
||||
|
||||
with patch("control_plane.observability.urlopen", return_value=response):
|
||||
payload = probe_test_deployment_health(deployment)
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert payload["status"] == "healthy"
|
||||
assert payload["payload"]["status"] == "ok"
|
||||
133
tests/test_control_plane_models.py
Normal file
133
tests/test_control_plane_models.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
|
||||
from control_plane.models import Deployment
|
||||
from control_plane.models import HostedSite
|
||||
from control_plane.models import RuntimeService
|
||||
from control_plane.models import RuntimeServiceKind
|
||||
from control_plane.models import Tenant
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_builds_uv_gunicorn_command() -> None:
|
||||
"""Deployment model should expose a reusable Django launch command."""
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
deployment = Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key="deploy-001",
|
||||
source_sha256="a" * 64,
|
||||
guest_port=8010,
|
||||
)
|
||||
|
||||
assert deployment.build_django_launch_command() == (
|
||||
"uv",
|
||||
"run",
|
||||
"gunicorn",
|
||||
"--bind",
|
||||
"0.0.0.0:8010",
|
||||
"--workers",
|
||||
"2",
|
||||
"--access-logfile",
|
||||
"-",
|
||||
"--error-logfile",
|
||||
"-",
|
||||
"--capture-output",
|
||||
"--graceful-timeout",
|
||||
"30",
|
||||
"--timeout",
|
||||
"60",
|
||||
"portal.wsgi:application",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_runtime_service_enforces_one_service_kind_per_deployment() -> None:
|
||||
"""A deployment should not create duplicate PostgreSQL or Redis resources."""
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
deployment = Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key="deploy-002",
|
||||
source_sha256="b" * 64,
|
||||
)
|
||||
RuntimeService.objects.create(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.POSTGRESQL,
|
||||
container_name="postgres-acme-portal",
|
||||
network_name="net-acme-portal",
|
||||
hostname="postgres.internal",
|
||||
image_reference="docker.io/library/postgres:17-alpine",
|
||||
internal_port=5432,
|
||||
connection_username="portal",
|
||||
connection_database="portal",
|
||||
connection_secret_ref="secret://postgres/acme/portal",
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
RuntimeService.objects.create(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.POSTGRESQL,
|
||||
container_name="postgres-acme-portal-2",
|
||||
network_name="net-acme-portal",
|
||||
hostname="postgres-2.internal",
|
||||
image_reference="docker.io/library/postgres:17-alpine",
|
||||
internal_port=5432,
|
||||
connection_username="portal",
|
||||
connection_database="portal",
|
||||
connection_secret_ref="secret://postgres/acme/portal-2",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_deployment_can_seed_missing_test_runtime_services() -> None:
|
||||
"""Deployment should create one PostgreSQL and one Redis runtime service idempotently."""
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
deployment = Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key="deploy-003",
|
||||
source_sha256="d" * 64,
|
||||
)
|
||||
|
||||
created_services = deployment.ensure_test_runtime_services()
|
||||
|
||||
assert {runtime_service.kind for runtime_service in created_services} == {
|
||||
RuntimeServiceKind.POSTGRESQL.value,
|
||||
RuntimeServiceKind.REDIS.value,
|
||||
}
|
||||
assert RuntimeService.objects.filter(deployment=deployment).count() == 2
|
||||
|
||||
postgres_service = RuntimeService.objects.get(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.POSTGRESQL,
|
||||
)
|
||||
redis_service = RuntimeService.objects.get(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.REDIS,
|
||||
)
|
||||
|
||||
assert postgres_service.connection_username
|
||||
assert postgres_service.connection_database == postgres_service.connection_username
|
||||
assert postgres_service.internal_port == 5432
|
||||
assert not redis_service.connection_username
|
||||
assert not redis_service.connection_database
|
||||
assert redis_service.internal_port == 6379
|
||||
assert deployment.ensure_test_runtime_services() == ()
|
||||
279
tests/test_control_plane_tasks.py
Normal file
279
tests/test_control_plane_tasks.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from control_plane import tasks as control_plane_tasks
|
||||
from control_plane.host_commands import HostCommandError
|
||||
from control_plane.host_commands import HostCommandResult
|
||||
from control_plane.models import Deployment
|
||||
from control_plane.models import DeploymentStatus
|
||||
from control_plane.models import HostedSite
|
||||
from control_plane.models import RuntimeService
|
||||
from control_plane.models import RuntimeServiceKind
|
||||
from control_plane.models import RuntimeServiceStatus
|
||||
from control_plane.models import Tenant
|
||||
from control_plane.tasks import mark_deployment_booting
|
||||
from control_plane.tasks import mark_deployment_provisioning
|
||||
from control_plane.tasks import provision_test_django_runtime
|
||||
from control_plane.tasks import provision_test_runtime_services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _create_deployment(*, status: str = DeploymentStatus.QUEUED.value, last_error: str = "") -> Deployment:
|
||||
tenant = Tenant.objects.create(slug="acme", display_name="Acme")
|
||||
hosted_site = HostedSite.objects.create(
|
||||
tenant=tenant,
|
||||
slug="portal",
|
||||
display_name="Portal",
|
||||
wsgi_module="portal.wsgi:application",
|
||||
)
|
||||
return Deployment.objects.create(
|
||||
hosted_site=hosted_site,
|
||||
idempotency_key=f"deploy-{status}",
|
||||
source_sha256=status[0] * 64,
|
||||
status=status,
|
||||
last_error=last_error,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_deployment_provisioning_moves_queued_deployment_to_provisioning() -> None:
|
||||
"""Provisioning task should persist the stored provisioning value and clear prior errors."""
|
||||
deployment = _create_deployment(last_error="previous failure")
|
||||
|
||||
result = mark_deployment_provisioning.run(str(deployment.id))
|
||||
deployment.refresh_from_db()
|
||||
|
||||
assert result == DeploymentStatus.PROVISIONING.value
|
||||
assert deployment.status == DeploymentStatus.PROVISIONING.value
|
||||
assert not deployment.last_error
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_deployment_provisioning_leaves_terminal_state_unchanged() -> None:
|
||||
"""Provisioning task should not mutate deployments already in a terminal state."""
|
||||
deployment = _create_deployment(
|
||||
status=DeploymentStatus.FAILED.value,
|
||||
last_error="still failed",
|
||||
)
|
||||
|
||||
result = mark_deployment_provisioning.run(str(deployment.id))
|
||||
deployment.refresh_from_db()
|
||||
|
||||
assert result == DeploymentStatus.FAILED.value
|
||||
assert deployment.status == DeploymentStatus.FAILED.value
|
||||
assert deployment.last_error == "still failed"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_deployment_booting_moves_provisioning_deployment_to_booting() -> None:
|
||||
"""Booting task should persist the stored booting value for active deployments."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.PROVISIONING.value)
|
||||
|
||||
result = mark_deployment_booting.run(str(deployment.id))
|
||||
deployment.refresh_from_db()
|
||||
|
||||
assert result == DeploymentStatus.BOOTING.value
|
||||
assert deployment.status == DeploymentStatus.BOOTING.value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provision_test_runtime_services_creates_and_launches_test_containers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Provisioning task should seed missing services and run Podman commands for them."""
|
||||
deployment = _create_deployment()
|
||||
|
||||
with (
|
||||
override_settings(DATA_DIR=tmp_path),
|
||||
patch(
|
||||
"control_plane.tasks.run_host_command",
|
||||
side_effect=[
|
||||
HostCommandError(
|
||||
"missing pod",
|
||||
args=("podman", "pod", "exists", "ignored"),
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "pod", "create", "ignored"),
|
||||
returncode=0,
|
||||
stdout="pod-id\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "run", "postgres"),
|
||||
returncode=0,
|
||||
stdout="postgres-id\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "inspect", "postgres"),
|
||||
returncode=0,
|
||||
stdout="healthy\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "pod", "exists", "ignored"),
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "run", "redis"),
|
||||
returncode=0,
|
||||
stdout="redis-id\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "inspect", "redis"),
|
||||
returncode=0,
|
||||
stdout="healthy\n",
|
||||
stderr="",
|
||||
),
|
||||
],
|
||||
) as mock_run_host_command,
|
||||
):
|
||||
result = provision_test_runtime_services.run(str(deployment.id))
|
||||
|
||||
runtime_services = tuple(RuntimeService.objects.filter(deployment=deployment).order_by("kind"))
|
||||
|
||||
assert result == RuntimeServiceStatus.READY.value
|
||||
assert len(runtime_services) == 2
|
||||
assert {runtime_service.status for runtime_service in runtime_services} == {
|
||||
RuntimeServiceStatus.READY.value,
|
||||
}
|
||||
for runtime_service in runtime_services:
|
||||
runtime_service_root = tmp_path / "runtime-services" / str(deployment.id) / runtime_service.kind
|
||||
assert (runtime_service_root / "data").is_dir() is True
|
||||
assert (runtime_service_root / "secrets" / "password").read_text(encoding="utf-8").strip()
|
||||
|
||||
assert mock_run_host_command.call_count == 7
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provision_test_runtime_services_skips_ready_runtime_services(tmp_path: Path) -> None:
|
||||
"""Provisioning task should remain idempotent once all runtime services are already ready."""
|
||||
deployment = _create_deployment()
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(deployment=deployment).update(status=RuntimeServiceStatus.READY.value)
|
||||
|
||||
with override_settings(DATA_DIR=tmp_path), patch("control_plane.tasks.run_host_command") as mock_run_host_command:
|
||||
result = provision_test_runtime_services.run(str(deployment.id))
|
||||
|
||||
assert result == RuntimeServiceStatus.READY.value
|
||||
mock_run_host_command.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provision_test_django_runtime_builds_and_runs_generated_app(tmp_path: Path) -> None:
|
||||
"""Django runtime task should build image, run migrations, and publish the app container."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.BOOTING.value)
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(deployment=deployment).update(status=RuntimeServiceStatus.READY.value)
|
||||
|
||||
with (
|
||||
override_settings(DATA_DIR=tmp_path),
|
||||
patch(
|
||||
"control_plane.tasks.run_host_command",
|
||||
side_effect=[
|
||||
HostCommandError(
|
||||
"missing image",
|
||||
args=("podman", "image", "exists", "ignored"),
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "build", "ignored"),
|
||||
returncode=0,
|
||||
stdout="built\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "run", "migrate"),
|
||||
returncode=0,
|
||||
stdout="migrated\n",
|
||||
stderr="",
|
||||
),
|
||||
HostCommandResult(
|
||||
args=("podman", "run", "django"),
|
||||
returncode=0,
|
||||
stdout="container-id\n",
|
||||
stderr="",
|
||||
),
|
||||
],
|
||||
) as mock_run_host_command,
|
||||
patch(
|
||||
"control_plane.tasks._wait_for_http_ready",
|
||||
return_value={"status": "ok", "postgres": 1, "redis": deployment.hosted_site.slug},
|
||||
) as mock_wait_for_http_ready,
|
||||
):
|
||||
result = provision_test_django_runtime.run(str(deployment.id))
|
||||
|
||||
deployment.refresh_from_db()
|
||||
project_root = tmp_path / "test-deployments" / str(deployment.id) / "django-app"
|
||||
|
||||
assert result == DeploymentStatus.RUNNING.value
|
||||
assert deployment.status == DeploymentStatus.RUNNING.value
|
||||
assert deployment.started_at is not None
|
||||
assert (project_root / "manage.py").is_file() is True
|
||||
assert (project_root / "tenant_site" / "settings.py").is_file() is True
|
||||
assert mock_run_host_command.call_count == 4
|
||||
assert mock_run_host_command.call_args_list[0].kwargs["command"][:3] == (
|
||||
"podman",
|
||||
"image",
|
||||
"exists",
|
||||
)
|
||||
assert "--rm" in mock_run_host_command.call_args_list[2].kwargs["command"]
|
||||
assert "--pod" in mock_run_host_command.call_args_list[3].kwargs["command"]
|
||||
assert "--publish" not in mock_run_host_command.call_args_list[3].kwargs["command"]
|
||||
mock_wait_for_http_ready.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_provision_test_django_runtime_marks_failure_when_services_are_not_ready(tmp_path: Path) -> None:
|
||||
"""Django runtime task should fail fast when backing services are not ready yet."""
|
||||
deployment = _create_deployment(status=DeploymentStatus.BOOTING.value)
|
||||
deployment.ensure_test_runtime_services()
|
||||
RuntimeService.objects.filter(
|
||||
deployment=deployment,
|
||||
kind=RuntimeServiceKind.POSTGRESQL.value,
|
||||
).update(status=RuntimeServiceStatus.FAILED.value)
|
||||
|
||||
with override_settings(DATA_DIR=tmp_path), pytest.raises(ValueError, match="must be ready"):
|
||||
provision_test_django_runtime.run(str(deployment.id))
|
||||
|
||||
deployment.refresh_from_db()
|
||||
assert deployment.status == DeploymentStatus.FAILED.value
|
||||
assert "must be ready" in deployment.last_error
|
||||
|
||||
|
||||
def test_wait_for_http_ready_retries_connection_reset() -> None:
|
||||
"""Sentinel polling should retry transient socket resets while Gunicorn finishes booting."""
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = b'{"status": "ok", "postgres": 1, "redis": "portal"}'
|
||||
|
||||
with (
|
||||
patch(
|
||||
"control_plane.tasks.urlopen",
|
||||
side_effect=[ConnectionResetError(104, "Connection reset by peer"), response],
|
||||
),
|
||||
patch("control_plane.tasks.time.sleep") as mock_sleep,
|
||||
):
|
||||
payload = control_plane_tasks._wait_for_http_ready(
|
||||
"http://127.0.0.1:18000/sentinel/",
|
||||
timeout_seconds=2.0,
|
||||
)
|
||||
|
||||
assert payload == {"status": "ok", "postgres": 1, "redis": "portal"}
|
||||
mock_sleep.assert_called_once_with(1.0)
|
||||
72
tests/test_create_test_deployment_command.py
Normal file
72
tests/test_create_test_deployment_command.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import override_settings
|
||||
|
||||
from control_plane.models import DeploymentStatus
|
||||
from control_plane.models import HostedSite
|
||||
from control_plane.models import Tenant
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_test_deployment_command_creates_randomized_records() -> None:
|
||||
"""Management command should create fresh tenant and site slugs for each invocation."""
|
||||
with (
|
||||
patch(
|
||||
"control_plane.management.commands.create_test_deployment.provision_test_deployment",
|
||||
return_value=SimpleNamespace(status=DeploymentStatus.RUNNING.value),
|
||||
) as mock_provision,
|
||||
):
|
||||
first_output = StringIO()
|
||||
second_output = StringIO()
|
||||
call_command("create_test_deployment", stdout=first_output)
|
||||
call_command("create_test_deployment", stdout=second_output)
|
||||
|
||||
tenants = tuple(Tenant.objects.order_by("created_at"))
|
||||
hosted_sites = tuple(HostedSite.objects.order_by("created_at"))
|
||||
|
||||
assert len(tenants) == 2
|
||||
assert len(hosted_sites) == 2
|
||||
assert tenants[0].slug != tenants[1].slug
|
||||
assert hosted_sites[0].slug != hosted_sites[1].slug
|
||||
assert mock_provision.call_count == 2
|
||||
assert "execution_mode=inline" in first_output.getvalue()
|
||||
assert "execution_mode=inline" in second_output.getvalue()
|
||||
assert "status=running" in first_output.getvalue()
|
||||
assert "status=running" in second_output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_test_deployment_command_can_return_without_waiting() -> None:
|
||||
"""Management command should support queue-only local workflows."""
|
||||
output = StringIO()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"control_plane.management.commands.create_test_deployment.queue_test_deployment_provisioning",
|
||||
return_value="task-1",
|
||||
) as mock_queue,
|
||||
patch(
|
||||
"control_plane.management.commands.create_test_deployment.provision_test_deployment",
|
||||
) as mock_provision,
|
||||
):
|
||||
call_command("create_test_deployment", no_wait=True, stdout=output)
|
||||
|
||||
assert mock_queue.call_count == 1
|
||||
mock_provision.assert_not_called()
|
||||
assert "status=queued" in output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_test_deployment_command_rejects_queue_only_mode_without_real_broker() -> None:
|
||||
"""Queue-only mode should fail fast when Celery has no usable cross-process broker."""
|
||||
output = StringIO()
|
||||
|
||||
with override_settings(CELERY_BROKER_URL="memory://"), pytest.raises(CommandError, match="cannot use memory://"):
|
||||
call_command("create_test_deployment", no_wait=True, stdout=output)
|
||||
61
tests/test_dev_autoreload.py
Normal file
61
tests/test_dev_autoreload.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.utils import autoreload
|
||||
from watchfiles import Change
|
||||
|
||||
from config.dev_autoreload import TussilagoWatchfilesReloader
|
||||
from config.dev_autoreload import build_project_watch_roots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_build_project_watch_roots_excludes_firecracker(tmp_path: Path) -> None:
|
||||
"""Project watch roots should skip firecracker and hidden directories."""
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "control_plane").mkdir()
|
||||
(tmp_path / "firecracker").mkdir()
|
||||
(tmp_path / ".venv").mkdir()
|
||||
(tmp_path / "static").mkdir()
|
||||
|
||||
assert build_project_watch_roots(tmp_path) == (
|
||||
tmp_path / "config",
|
||||
tmp_path / "control_plane",
|
||||
tmp_path / "static",
|
||||
)
|
||||
|
||||
|
||||
def test_reloader_replaces_repo_root_with_child_dirs(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Repo root watch should be split so firecracker is excluded."""
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "control_plane").mkdir()
|
||||
(tmp_path / "firecracker").mkdir()
|
||||
(tmp_path / "static").mkdir()
|
||||
|
||||
watched_settings = tmp_path / "config" / "settings.py"
|
||||
watched_settings.touch()
|
||||
firecracker_config = tmp_path / "firecracker" / "vm_config.json"
|
||||
firecracker_config.touch()
|
||||
|
||||
monkeypatch.setattr(
|
||||
autoreload,
|
||||
"sys_path_directories",
|
||||
lambda: iter((tmp_path, tmp_path / "config")),
|
||||
)
|
||||
|
||||
reloader = TussilagoWatchfilesReloader(project_root=tmp_path)
|
||||
roots = reloader.watched_roots((tmp_path / "manage.py", watched_settings))
|
||||
|
||||
assert tmp_path not in roots
|
||||
assert tmp_path / "config" in roots
|
||||
assert tmp_path / "control_plane" in roots
|
||||
assert tmp_path / "static" in roots
|
||||
assert tmp_path / "firecracker" not in roots
|
||||
assert reloader.file_filter(Change.added, str(firecracker_config)) is False
|
||||
84
tests/test_host_commands.py
Normal file
84
tests/test_host_commands.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess # noqa: S404
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from control_plane.host_commands import HostCommandError
|
||||
from control_plane.host_commands import HostCommandResult
|
||||
from control_plane.host_commands import build_host_command_env
|
||||
from control_plane.host_commands import run_host_command
|
||||
|
||||
|
||||
def test_run_host_command_rejects_env_without_allowlist() -> None:
|
||||
"""Environment overrides must use an explicit allowlist."""
|
||||
with pytest.raises(ValueError, match="allowed_env_keys"):
|
||||
run_host_command(command=("true",), env_overrides={"SECRET": "value"})
|
||||
|
||||
|
||||
def test_run_host_command_returns_captured_output() -> None:
|
||||
"""Successful host commands should preserve stdout and stderr."""
|
||||
completed = subprocess.CompletedProcess(
|
||||
args=("echo", "ok"),
|
||||
returncode=0,
|
||||
stdout="ok\n",
|
||||
stderr="",
|
||||
)
|
||||
with patch(
|
||||
"control_plane.host_commands.subprocess.run",
|
||||
return_value=completed,
|
||||
) as mock_run:
|
||||
result = run_host_command(
|
||||
command=("echo", "ok"),
|
||||
cwd=Path.cwd(),
|
||||
env_overrides={"UV_PROJECT_ENVIRONMENT": "test"},
|
||||
allowed_env_keys=frozenset({"UV_PROJECT_ENVIRONMENT"}),
|
||||
)
|
||||
|
||||
assert result == HostCommandResult(
|
||||
args=("echo", "ok"),
|
||||
returncode=0,
|
||||
stdout="ok\n",
|
||||
stderr="",
|
||||
)
|
||||
forwarded_env = mock_run.call_args.kwargs["env"]
|
||||
assert "DJANGO_SETTINGS_MODULE" not in forwarded_env
|
||||
assert forwarded_env["UV_PROJECT_ENVIRONMENT"] == "test"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
def test_build_host_command_env_strips_platform_django_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Tenant child processes must not inherit platform Django settings."""
|
||||
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
environment = build_host_command_env()
|
||||
|
||||
assert environment["PATH"] == "/usr/bin"
|
||||
assert "DJANGO_SETTINGS_MODULE" not in environment
|
||||
|
||||
|
||||
def test_run_host_command_wraps_called_process_errors() -> None:
|
||||
"""Failing host commands should raise a typed exception with captured output."""
|
||||
error = subprocess.CalledProcessError(
|
||||
returncode=17,
|
||||
cmd=("podman", "run"),
|
||||
output="",
|
||||
stderr="boom",
|
||||
)
|
||||
with (
|
||||
patch("control_plane.host_commands.subprocess.run", side_effect=error),
|
||||
pytest.raises(
|
||||
HostCommandError,
|
||||
match="Host command failed",
|
||||
) as exc_info,
|
||||
):
|
||||
run_host_command(command=("podman", "run"))
|
||||
|
||||
assert exc_info.value.command_args == ("podman", "run")
|
||||
assert exc_info.value.returncode == 17
|
||||
assert exc_info.value.stderr == "boom"
|
||||
219
tests/test_runtime_plans.py
Normal file
219
tests/test_runtime_plans.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from control_plane.runtime_plans import DjangoApplicationLaunchConfig
|
||||
from control_plane.runtime_plans import DjangoContainerImageBuildConfig
|
||||
from control_plane.runtime_plans import DjangoContainerRuntimeConfig
|
||||
from control_plane.runtime_plans import PostgresContainerConfig
|
||||
from control_plane.runtime_plans import RedisContainerConfig
|
||||
from control_plane.runtime_plans import build_django_container_image_command
|
||||
from control_plane.runtime_plans import build_django_container_run_command
|
||||
from control_plane.runtime_plans import build_django_migrate_command
|
||||
from control_plane.runtime_plans import build_django_server_command
|
||||
from control_plane.runtime_plans import build_postgres_container_command
|
||||
from control_plane.runtime_plans import build_redis_container_command
|
||||
|
||||
|
||||
def test_build_postgres_container_command_uses_hardening_flags() -> None:
|
||||
"""PostgreSQL container plan should include core isolation and auth settings."""
|
||||
command = build_postgres_container_command(
|
||||
PostgresContainerConfig(
|
||||
container_name="postgres-tenant-site",
|
||||
network_name="tenant-net",
|
||||
hostname="postgres.internal",
|
||||
username="site",
|
||||
database_name="site",
|
||||
data_directory=Path("/var/lib/tussilago/postgres/site"),
|
||||
password_file=Path("/run/tussilago/secrets/postgres-password"),
|
||||
),
|
||||
)
|
||||
|
||||
assert "--cap-drop=all" in command
|
||||
assert "--cap-add=CHOWN" in command
|
||||
assert "--cap-add=FOWNER" in command
|
||||
assert "--cap-add=SETUID" in command
|
||||
assert "--cap-add=SETGID" in command
|
||||
assert "--cap-add=DAC_OVERRIDE" in command
|
||||
assert "--security-opt=no-new-privileges" in command
|
||||
assert "POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password" in command
|
||||
assert "password_encryption=scram-sha-256" in command
|
||||
assert command[-1] == "password_encryption=scram-sha-256"
|
||||
|
||||
|
||||
def test_build_redis_container_command_requires_password() -> None:
|
||||
"""Redis container plan should enforce password auth and readonly base image."""
|
||||
command = build_redis_container_command(
|
||||
RedisContainerConfig(
|
||||
container_name="redis-tenant-site",
|
||||
network_name="tenant-net",
|
||||
hostname="redis.internal",
|
||||
data_directory=Path("/var/lib/tussilago/redis/site"),
|
||||
password_file=Path("/run/tussilago/secrets/redis-password"),
|
||||
),
|
||||
)
|
||||
|
||||
assert "--read-only" in command
|
||||
assert "/run/secrets/redis-password" in command[-1]
|
||||
assert command[-1].startswith("redis_password=$(cat /run/secrets/redis-password)")
|
||||
|
||||
|
||||
def test_build_django_server_command_supports_project_reuse() -> None:
|
||||
"""Gunicorn command should optionally reuse the repo-managed uv project."""
|
||||
command = build_django_server_command(
|
||||
DjangoApplicationLaunchConfig(
|
||||
wsgi_module="tenant_site.wsgi:application",
|
||||
bind_host="127.0.0.1",
|
||||
port=9000,
|
||||
workers=1,
|
||||
uv_project_path=Path("/workspace/Tussilago"),
|
||||
),
|
||||
)
|
||||
|
||||
assert command == (
|
||||
"uv",
|
||||
"run",
|
||||
"--project",
|
||||
"/workspace/Tussilago",
|
||||
"gunicorn",
|
||||
"--bind",
|
||||
"127.0.0.1:9000",
|
||||
"--workers",
|
||||
"1",
|
||||
"--access-logfile",
|
||||
"-",
|
||||
"--error-logfile",
|
||||
"-",
|
||||
"--capture-output",
|
||||
"--graceful-timeout",
|
||||
"30",
|
||||
"--timeout",
|
||||
"60",
|
||||
"tenant_site.wsgi:application",
|
||||
)
|
||||
|
||||
|
||||
def test_build_django_migrate_command_supports_repo_reuse() -> None:
|
||||
"""Migration command should optionally reuse repo-managed uv dependencies."""
|
||||
assert build_django_migrate_command(Path("/workspace/Tussilago")) == (
|
||||
"uv",
|
||||
"run",
|
||||
"--project",
|
||||
"/workspace/Tussilago",
|
||||
"python",
|
||||
"manage.py",
|
||||
"migrate",
|
||||
"--noinput",
|
||||
)
|
||||
|
||||
|
||||
def test_build_django_migrate_command_supports_explicit_python_executable() -> None:
|
||||
"""Migration command should also support container-local Python execution."""
|
||||
assert build_django_migrate_command(python_executable=Path("/usr/local/bin/python")) == (
|
||||
"/usr/local/bin/python",
|
||||
"manage.py",
|
||||
"migrate",
|
||||
"--noinput",
|
||||
)
|
||||
|
||||
|
||||
def test_build_django_container_image_command_targets_checked_in_containerfile() -> None:
|
||||
"""Image build plan should reference an explicit Containerfile and context."""
|
||||
command = build_django_container_image_command(
|
||||
DjangoContainerImageBuildConfig(
|
||||
image_reference="localhost/tussilago-test-django:latest",
|
||||
containerfile_path=Path("/workspace/Tussilago/control_plane/container_assets/test_django/Containerfile"),
|
||||
context_directory=Path("/workspace/Tussilago/control_plane/container_assets/test_django"),
|
||||
),
|
||||
)
|
||||
|
||||
assert command == (
|
||||
"podman",
|
||||
"build",
|
||||
"--pull=missing",
|
||||
"--tag",
|
||||
"localhost/tussilago-test-django:latest",
|
||||
"--file",
|
||||
"/workspace/Tussilago/control_plane/container_assets/test_django/Containerfile",
|
||||
"/workspace/Tussilago/control_plane/container_assets/test_django",
|
||||
)
|
||||
|
||||
|
||||
def test_build_django_container_run_command_uses_publish_and_secret_mounts() -> None:
|
||||
"""Django runtime plan should publish localhost only and mount generated assets read-only."""
|
||||
command = build_django_container_run_command(
|
||||
DjangoContainerRuntimeConfig(
|
||||
container_name="django-tenant-site",
|
||||
network_name="tenant-net",
|
||||
hostname="django.internal",
|
||||
image_reference="localhost/tussilago-test-django:latest",
|
||||
application_directory=Path("/var/lib/tussilago/test-app"),
|
||||
host_port=19000,
|
||||
environment=(
|
||||
("DJANGO_SECRET_KEY", "secret"),
|
||||
("DJANGO_SETTINGS_MODULE", "tenant_site.settings"),
|
||||
),
|
||||
secret_mounts=((Path("/var/lib/tussilago/postgres-secret"), "/run/postgres-secret"),),
|
||||
labels=(("tussilago.deployment-id", "deployment-1"),),
|
||||
),
|
||||
command=("/usr/local/bin/python", "-m", "gunicorn"),
|
||||
detach=True,
|
||||
)
|
||||
|
||||
assert "--read-only" in command
|
||||
assert "--publish" in command
|
||||
assert "127.0.0.1:19000:8000" in command
|
||||
assert "/var/lib/tussilago/test-app:/srv/test-app:Z,ro" in command
|
||||
assert "/var/lib/tussilago/postgres-secret:/run/postgres-secret:Z,ro" in command
|
||||
assert "DJANGO_SETTINGS_MODULE=tenant_site.settings" in command
|
||||
assert "tussilago.deployment-id=deployment-1" in command
|
||||
|
||||
|
||||
def test_build_django_container_run_command_supports_pod_membership() -> None:
|
||||
"""Django runtime plan should join a Podman pod instead of publishing ports when requested."""
|
||||
command = build_django_container_run_command(
|
||||
DjangoContainerRuntimeConfig(
|
||||
container_name="django-tenant-site",
|
||||
network_name="ignored-net",
|
||||
hostname="django.internal",
|
||||
image_reference="localhost/tussilago-test-django:latest",
|
||||
application_directory=Path("/var/lib/tussilago/test-app"),
|
||||
pod_name="pod-tenant-site",
|
||||
host_port=19000,
|
||||
),
|
||||
command=("/usr/local/bin/python", "-m", "gunicorn"),
|
||||
detach=True,
|
||||
)
|
||||
|
||||
assert "--pod" in command
|
||||
assert "pod-tenant-site" in command
|
||||
assert "--publish" not in command
|
||||
|
||||
|
||||
def test_build_django_server_command_supports_explicit_python_executable() -> None:
|
||||
"""Local smoke flows may launch Gunicorn via an explicit Python executable."""
|
||||
command = build_django_server_command(
|
||||
DjangoApplicationLaunchConfig(
|
||||
wsgi_module="tenant_site.wsgi:application",
|
||||
bind_host="127.0.0.1",
|
||||
port=9000,
|
||||
workers=1,
|
||||
python_executable=Path("/venv/bin/python"),
|
||||
),
|
||||
)
|
||||
|
||||
assert command[:3] == ("/venv/bin/python", "-m", "gunicorn")
|
||||
|
||||
|
||||
def test_build_django_server_command_rejects_conflicting_python_modes() -> None:
|
||||
"""Server command builder should reject mixed uv and direct-python execution modes."""
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
build_django_server_command(
|
||||
DjangoApplicationLaunchConfig(
|
||||
wsgi_module="tenant_site.wsgi:application",
|
||||
python_executable=Path("/venv/bin/python"),
|
||||
uv_project_path=Path("/workspace/Tussilago"),
|
||||
),
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ import sys
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from django.utils import autoreload
|
||||
|
||||
|
||||
def load_settings_for_runtime(
|
||||
|
|
@ -29,9 +30,13 @@ def load_settings_for_runtime(
|
|||
monkeypatch.setattr(target="sys.argv", name=["manage.py", "runserver"])
|
||||
|
||||
# Remove the settings module from sys.modules to force it to be reloaded with the new environment variables.
|
||||
original_get_reloader = autoreload.get_reloader
|
||||
sys.modules.pop("config.settings", None)
|
||||
settings_module = importlib.import_module("config.settings")
|
||||
return vars(settings_module).copy()
|
||||
try:
|
||||
settings_module = importlib.import_module("config.settings")
|
||||
return vars(settings_module).copy()
|
||||
finally:
|
||||
autoreload.get_reloader = original_get_reloader
|
||||
|
||||
|
||||
def test_production_runtime_disables_debug_stuff(
|
||||
|
|
|
|||
115
tests/test_temp_django_smoke.py
Normal file
115
tests/test_temp_django_smoke.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import subprocess # noqa: S404
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
import pytest
|
||||
|
||||
from control_plane.host_commands import build_host_command_env
|
||||
from control_plane.runtime_plans import DjangoApplicationLaunchConfig
|
||||
from control_plane.runtime_plans import build_django_server_command
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
probe.listen(1)
|
||||
return int(probe.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_http_ready(url: str, *, timeout_seconds: float = 15.0) -> str:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as response: # noqa: S310
|
||||
return response.read().decode("utf-8")
|
||||
except URLError:
|
||||
time.sleep(0.25)
|
||||
|
||||
msg = f"Timed out waiting for {url}"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
|
||||
def _write_sentinel_urls(project_dir: Path, project_name: str) -> None:
|
||||
urls_file = project_dir / project_name / "urls.py"
|
||||
urls_file.write_text(
|
||||
"from django.http import HttpResponse\n"
|
||||
"from django.urls import path\n\n"
|
||||
"def sentinel_view(request):\n"
|
||||
" return HttpResponse('sentinel-ok')\n\n"
|
||||
"urlpatterns = [\n"
|
||||
" path('sentinel/', sentinel_view),\n"
|
||||
"]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.host_smoke
|
||||
def test_temp_django_project_serves_content(
|
||||
host_smoke_enabled: bool,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Opt-in smoke test should create a temp Django project and serve a sentinel view."""
|
||||
if not host_smoke_enabled:
|
||||
pytest.skip("Set TUSSILAGO_RUN_HOST_SMOKE=1 to run host smoke coverage.")
|
||||
|
||||
workspace_root = Path(__file__).resolve().parents[1]
|
||||
project_name = "guestsite"
|
||||
project_dir = tmp_path / project_name
|
||||
child_process_env = build_host_command_env()
|
||||
startproject_command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"django",
|
||||
"startproject",
|
||||
project_name,
|
||||
str(project_dir),
|
||||
]
|
||||
|
||||
subprocess.run( # noqa: S603
|
||||
startproject_command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=workspace_root,
|
||||
env=child_process_env,
|
||||
timeout=30,
|
||||
)
|
||||
_write_sentinel_urls(project_dir, project_name)
|
||||
|
||||
port = _find_free_port()
|
||||
command = build_django_server_command(
|
||||
DjangoApplicationLaunchConfig(
|
||||
wsgi_module=f"{project_name}.wsgi:application",
|
||||
bind_host="127.0.0.1",
|
||||
port=port,
|
||||
workers=1,
|
||||
python_executable=Path(sys.executable),
|
||||
),
|
||||
)
|
||||
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
cwd=project_dir,
|
||||
env=child_process_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
response_body = _wait_for_http_ready(f"http://127.0.0.1:{port}/sentinel/")
|
||||
except TimeoutError as error:
|
||||
process.terminate()
|
||||
stdout, stderr = process.communicate(timeout=10)
|
||||
pytest.fail(f"{error}\nstdout:\n{stdout}\nstderr:\n{stderr}")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
|
||||
assert response_body == "sentinel-ok"
|
||||
Loading…
Add table
Add a link
Reference in a new issue