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

340 lines
12 KiB
Python

from __future__ import annotations
import uuid
from dataclasses import dataclass
import auto_prefetch
from auto_prefetch import ForeignKey
from auto_prefetch import Manager
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.db import models
from django.db import transaction
from control_plane.runtime_plans import DjangoApplicationLaunchConfig
from control_plane.runtime_plans import build_django_server_command
class TimestampedModel(auto_prefetch.Model):
"""Provide created and updated timestamps for control-plane records."""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(auto_prefetch.Model.Meta):
abstract = True
class DeploymentStatus(models.TextChoices):
"""Track deployment lifecycle state inside control plane."""
QUEUED = "queued", "Queued"
PROVISIONING = "provisioning", "Provisioning"
BOOTING = "booting", "Booting"
RUNNING = "running", "Running"
FAILED = "failed", "Failed"
STOPPED = "stopped", "Stopped"
DESTROYING = "destroying", "Destroying"
DESTROYED = "destroyed", "Destroyed"
class RuntimeServiceKind(models.TextChoices):
"""Enumerate deployment-scoped backing services."""
POSTGRESQL = "postgresql", "PostgreSQL"
REDIS = "redis", "Redis"
class RuntimeServiceStatus(models.TextChoices):
"""Track lifecycle state for a deployment-scoped service."""
QUEUED = "queued", "Queued"
PROVISIONING = "provisioning", "Provisioning"
READY = "ready", "Ready"
FAILED = "failed", "Failed"
DESTROYING = "destroying", "Destroying"
DESTROYED = "destroyed", "Destroyed"
@dataclass(frozen=True, slots=True)
class RuntimeServiceSeedSpec:
"""Describe default values for admin-seeded test runtime services."""
hostname: str
image_reference: str
internal_port: int
RUNTIME_SERVICE_SEED_SPECS: dict[RuntimeServiceKind, RuntimeServiceSeedSpec] = {
RuntimeServiceKind.POSTGRESQL: RuntimeServiceSeedSpec(
hostname="postgres.internal",
image_reference="docker.io/library/postgres:17-alpine",
internal_port=5432,
),
RuntimeServiceKind.REDIS: RuntimeServiceSeedSpec(
hostname="redis.internal",
image_reference="docker.io/library/redis:7.4-alpine",
internal_port=6379,
),
}
def _build_limited_identifier(
*,
prefix: str,
tenant_slug: str,
site_slug: str,
suffix: str,
max_length: int,
) -> str:
"""Build a bounded identifier while preserving deployment uniqueness.
Args:
prefix: Static prefix to identify the type of resource (e.g. "net" or
"postgres").
tenant_slug: Hosted site tenant slug to include in the name for uniqueness.
site_slug: Hosted site slug to include in the name for uniqueness.
suffix: Unique suffix to ensure no collisions across deployments of the same site.
max_length: Maximum length for the resulting identifier.
Returns:
A string that combines the prefix, tenant slug, site slug, and suffix,
truncated as needed to fit within max_length.
"""
candidate = f"{prefix}-{tenant_slug}-{site_slug}-{suffix}"
if len(candidate) <= max_length:
return candidate
min_length = len(prefix) + len(suffix) + 2
if min_length >= max_length:
return f"{prefix}-{suffix}"[:max_length]
remaining_length = max_length - len(prefix) - len(suffix) - 3
tenant_budget = max(1, remaining_length // 2)
site_budget = max(1, remaining_length - tenant_budget)
return "-".join(
(
prefix,
tenant_slug[:tenant_budget],
site_slug[:site_budget],
suffix,
),
)
def _build_limited_connection_name(*, site_slug: str, suffix: str, max_length: int = 63) -> str:
"""Build a bounded database identifier that stays unique per deployment.
Args:
site_slug: Hosted site slug to include in the name for uniqueness.
suffix: Unique suffix to ensure no collisions across deployments of the same site.
max_length: Maximum length for the resulting identifier, defaulting to 63 for database compatibility
Returns:
A string that combines the site slug and suffix, truncated as needed to fit within max_length.
"""
candidate = f"{site_slug}-{suffix}"
if len(candidate) <= max_length:
return candidate
min_length = len(suffix) + 1
if min_length >= max_length:
return suffix[:max_length]
site_budget = max_length - len(suffix) - 1
return f"{site_slug[:site_budget]}-{suffix}"
class Tenant(TimestampedModel):
"""Represent a tenant that owns hosted applications and deployments."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.SlugField(max_length=64, unique=True)
display_name = models.CharField(max_length=255)
objects = Manager()
class Meta(TimestampedModel.Meta):
ordering = ("slug",)
indexes = [models.Index(fields=("slug",), name="tenant_slug_idx")]
def __str__(self) -> str:
return self.display_name
class HostedSite(TimestampedModel):
"""Describe a deployable Django site owned by a tenant."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant = ForeignKey(Tenant, on_delete=models.CASCADE, related_name="hosted_sites")
slug = models.SlugField(max_length=64)
display_name = models.CharField(max_length=255)
working_directory = models.CharField(max_length=255, default=".")
wsgi_module = models.CharField(max_length=255)
service_port = models.PositiveIntegerField(
default=8000,
validators=[MinValueValidator(1024), MaxValueValidator(65535)],
)
objects = Manager()
class Meta(TimestampedModel.Meta):
ordering = ("tenant__slug", "slug")
constraints = [
models.UniqueConstraint(
fields=("tenant", "slug"),
name="hosted_site_unique_tenant_slug",
),
]
indexes = [
models.Index(fields=("tenant", "slug"), name="site_tenant_slug_idx"),
]
def __str__(self) -> str:
return f"{self.tenant.slug}/{self.slug}"
class Deployment(TimestampedModel):
"""Track a single deployable runtime instance for a hosted site."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
hosted_site = ForeignKey(HostedSite, on_delete=models.CASCADE, related_name="deployments")
idempotency_key = models.CharField(max_length=64, unique=True)
source_sha256 = models.CharField(max_length=64)
status = models.CharField(
max_length=32,
choices=DeploymentStatus,
default=DeploymentStatus.QUEUED,
)
guest_ipv4 = models.GenericIPAddressField(protocol="IPv4", blank=True, null=True)
guest_port = models.PositiveIntegerField(
default=8000,
validators=[MinValueValidator(1024), MaxValueValidator(65535)],
)
firecracker_vm_id = models.CharField(max_length=64, blank=True, null=True, unique=True)
last_error = models.TextField(blank=True)
started_at = models.DateTimeField(blank=True, null=True)
finished_at = models.DateTimeField(blank=True, null=True)
objects = Manager()
class Meta(TimestampedModel.Meta):
ordering = ("-created_at",)
indexes = [
models.Index(fields=("hosted_site", "status"), name="deploy_site_status_idx"),
models.Index(fields=("status", "created_at"), name="deploy_status_created_idx"),
]
def __str__(self) -> str:
return f"{self.hosted_site} [{self.status}]"
def build_django_launch_command(self) -> tuple[str, ...]:
"""Build a uv-driven Gunicorn command for this deployment's Django app.
Returns:
Tuple of command arguments ready for subprocess execution inside a guest VM.
"""
config = DjangoApplicationLaunchConfig(
wsgi_module=self.hosted_site.wsgi_module,
bind_host="0.0.0.0", # noqa: S104
port=self.guest_port,
)
return build_django_server_command(config)
def ensure_test_runtime_services(self) -> tuple[RuntimeService, ...]:
"""Create missing test runtime services for all supported service kinds.
Returns:
Newly created runtime service records.
"""
tenant_slug = self.hosted_site.tenant.slug
site_slug = self.hosted_site.slug
deployment_suffix = self.id.hex[:12]
network_name = _build_limited_identifier(
prefix="net",
tenant_slug=tenant_slug,
site_slug=site_slug,
suffix=deployment_suffix,
max_length=128,
)
connection_name = _build_limited_connection_name(
site_slug=site_slug,
suffix=deployment_suffix,
)
created_services: list[RuntimeService] = []
with transaction.atomic():
existing_kinds = set(
RuntimeService.objects.filter(deployment=self).values_list("kind", flat=True),
)
for kind, seed_spec in RUNTIME_SERVICE_SEED_SPECS.items():
if kind.value in existing_kinds:
continue
created_services.append(
RuntimeService(
deployment=self,
kind=kind.value,
status=RuntimeServiceStatus.QUEUED.value,
container_name=_build_limited_identifier(
prefix=kind.value,
tenant_slug=tenant_slug,
site_slug=site_slug,
suffix=deployment_suffix,
max_length=128,
),
network_name=network_name,
hostname=seed_spec.hostname,
image_reference=seed_spec.image_reference,
internal_port=seed_spec.internal_port,
connection_username=connection_name if kind == RuntimeServiceKind.POSTGRESQL else "",
connection_database=connection_name if kind == RuntimeServiceKind.POSTGRESQL else "",
connection_secret_ref=(f"secret://{kind.value}/{tenant_slug}/{site_slug}/{deployment_suffix}"),
),
)
if created_services:
RuntimeService.objects.bulk_create(created_services)
return tuple(created_services)
class RuntimeService(TimestampedModel):
"""Track a dedicated PostgreSQL or Redis service for one deployment."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
deployment = ForeignKey(Deployment, on_delete=models.CASCADE, related_name="runtime_services")
kind = models.CharField(max_length=32, choices=RuntimeServiceKind)
status = models.CharField(
max_length=32,
choices=RuntimeServiceStatus,
default=RuntimeServiceStatus.QUEUED,
)
container_name = models.CharField(max_length=128, unique=True)
network_name = models.CharField(max_length=128)
hostname = models.CharField(max_length=128)
image_reference = models.CharField(max_length=255)
internal_port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)],
)
connection_username = models.CharField(max_length=63, blank=True)
connection_database = models.CharField(max_length=63, blank=True)
connection_secret_ref = models.CharField(max_length=255)
objects = Manager()
class Meta(TimestampedModel.Meta):
ordering = ("deployment__created_at", "kind")
constraints = [
models.UniqueConstraint(
fields=("deployment", "kind"),
name="runtime_service_unique_deployment_kind",
),
]
indexes = [
models.Index(fields=("deployment", "kind"), name="service_deploy_kind_idx"),
models.Index(fields=("kind", "status"), name="service_kind_status_idx"),
]
def __str__(self) -> str:
return f"{self.deployment_id}:{self.kind}"