This commit is contained in:
Joakim Hellsén 2026-04-27 20:43:26 +02:00
commit a7a5b5c8ea
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
43 changed files with 5531 additions and 9 deletions

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

View 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"

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

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

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

View 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

View 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
View 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"),
),
)

View file

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

View 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"