340 lines
12 KiB
Python
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}"
|