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