Service instantiation #31

Merged
rixx merged 37 commits from 24-service-instantiation into main 2025-04-04 10:57:29 +00:00
32 changed files with 1168 additions and 524 deletions

View file

@ -12,6 +12,7 @@ dependencies = [
"django-fernet-encrypted-fields>=0.3.0",
"django-scopes>=2.0.0",
"django-template-partials>=24.4",
"jsonschema>=4.23.0",
"kubernetes>=32.0.1",
"pillow>=11.1.0",
"psycopg2-binary>=2.9.10",
@ -42,6 +43,7 @@ known_first_party = "servala"
[tool.flake8]
max-line-length = 160
exclude = ".venv"
ignore = "E203"
[tool.djlint]
extend_exclude = "src/servala/static/mazer"

View file

@ -6,6 +6,7 @@ from servala.core.models import (
BillingEntity,
CloudProvider,
ControlPlane,
ControlPlaneCRD,
Organization,
OrganizationMembership,
OrganizationOrigin,
@ -13,8 +14,8 @@ from servala.core.models import (
Service,
ServiceCategory,
ServiceDefinition,
ServiceInstance,
ServiceOffering,
ServiceOfferingControlPlane,
User,
)
@ -56,12 +57,18 @@ class OrganizationMembershipInline(admin.TabularInline):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ("name", "billing_entity", "origin")
list_display = ("name", "namespace", "billing_entity", "origin")
list_filter = ("origin",)
search_fields = ("name",)
search_fields = ("name", "namespace")
autocomplete_fields = ("billing_entity", "origin")
inlines = (OrganizationMembershipInline,)
def get_readonly_fields(self, request, obj=None):
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
if obj: # If this is an edit (not a new organization)
readonly_fields.append("namespace")
return readonly_fields
@admin.register(BillingEntity)
class BillingEntityAdmin(admin.ModelAdmin):
@ -124,7 +131,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
{"fields": ("name", "description", "cloud_provider")},
{"fields": ("name", "description", "cloud_provider", "required_label")},
),
(
_("API Credentials"),
@ -200,20 +207,55 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
return ["api_definition"]
class ServiceOfferingControlPlaneInline(admin.TabularInline):
model = ServiceOfferingControlPlane
class ControlPlaneCRDInline(admin.TabularInline):
model = ControlPlaneCRD
extra = 1
autocomplete_fields = ("control_plane", "service_definition")
@admin.register(ServiceOfferingControlPlane)
class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
@admin.register(ControlPlaneCRD)
class ControlPlaneCRDAdmin(admin.ModelAdmin):
list_display = ("service_offering", "control_plane", "service_definition")
list_filter = ("service_offering", "control_plane", "service_definition")
search_fields = ("service_offering__service__name", "control_plane__name")
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
@admin.register(ServiceInstance)
class ServiceInstanceAdmin(admin.ModelAdmin):
list_display = ("name", "organization", "context", "created_by", "is_deleted")
list_filter = ("organization", "context", "is_deleted")
search_fields = (
"name",
"organization__name",
"context__service_offering__service__name",
)
readonly_fields = ("name", "organization", "context")
autocomplete_fields = ("organization", "context")
def get_readonly_fields(self, request, obj=None):
if obj: # If this is an edit (not a new instance)
return self.readonly_fields
return []
fieldsets = (
(
None,
{
"fields": (
"name",
"organization",
"context",
"created_by",
"is_deleted",
"deleted_at",
"deleted_by",
)
},
),
)
@admin.register(ServiceOffering)
class ServiceOfferingAdmin(admin.ModelAdmin):
list_display = ("id", "service", "provider")
@ -221,6 +263,6 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
inlines = (
ServiceOfferingControlPlaneInline,
ControlPlaneCRDInline,
PlanInline,
)

View file

@ -1,20 +1,46 @@
import re
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.forms.models import ModelForm, ModelFormMetaclass
from django.utils.translation import gettext_lazy as _
from servala.core.models import ServiceInstance
def duplicate_field(field_name, model):
# Get the field from the model
field = model._meta.get_field(field_name)
# Create a new field with the same attributes
new_field = type(field).__new__(type(field))
new_field.__dict__.update(field.__dict__)
# Ensure the field is not linked to the original model
new_field.model = None
new_field.auto_created = False
return new_field
def generate_django_model(schema, group, version, kind):
"""
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
"""
spec = schema["properties"].get("spec") or {}
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
# We always need these three fields to know our own name and our full namespace
model_fields = {"__module__": "crd_models"}
model_fields.update(build_object_fields(spec, "spec"))
for field_name in ("name", "organization", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the
# resourceRef object
spec = schema["properties"].get("spec") or {}
spec["properties"].pop("resourceRef", None)
model_fields.update(build_object_fields(spec, "spec", parent_required=True))
# Store the original schema on the model class
model_fields["SCHEMA"] = schema
meta_class = type("Meta", (), {"app_label": "crd_models"})
model_fields["Meta"] = meta_class
@ -25,13 +51,13 @@ def generate_django_model(schema, group, version, kind):
return model_class
def build_object_fields(schema, name, verbose_name_prefix=None):
def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
required_fields = schema.get("required") or []
properties = schema.get("properties") or {}
fields = {}
for field_name, field_schema in properties.items():
is_required = field_name in required_fields
is_required = field_name in required_fields and parent_required
full_name = f"{name}.{field_name}"
result = get_django_field(
field_schema,
@ -62,8 +88,9 @@ def get_django_field(
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
# Pass down the requirement status from parent to child fields
kwargs = {
"blank": not is_required,
"blank": not is_required, # All fields are optional by default
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
@ -95,8 +122,12 @@ def get_django_field(
elif field_type == "boolean":
return models.BooleanField(**kwargs)
elif field_type == "object":
# Here we pass down the requirement status to nested objects
return build_object_fields(
field_schema, full_name, verbose_name_prefix=f"{verbose_name}:"
field_schema,
full_name,
verbose_name_prefix=f"{verbose_name}:",
parent_required=is_required,
)
elif field_type == "array":
# TODO: handle items / validate items, build multi-select input
@ -110,8 +141,126 @@ def get_django_field(
class CrdModelFormMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.fields["apiVersion"].disabled = True
# self.fields["kind"].disabled = True
self.schema = self._meta.model.SCHEMA
for field in ("organization", "context"):
self.fields[field].widget = forms.HiddenInput()
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label.startswith(label):
field.label = field.label[len(label) :]
def get_fieldsets(self):
fieldsets = []
# General fieldset for non-spec fields
general_fields = [
field for field in self.fields if not field.startswith("spec.")
]
if general_fields:
fieldsets.append(
{"title": "General", "fields": general_fields, "fieldsets": []}
)
# Process spec fields
others = []
nested_fieldsets = {}
for field_name in self.fields:
if field_name.startswith("spec."):
parts = field_name.split(".")
if len(parts) == 2: # Top-level spec field
others.append(field_name)
else:
parent_key = parts[1]
if not nested_fieldsets.get(parent_key):
nested_fieldsets[parent_key] = {
"fields": [],
"fieldsets": {},
"title": deslugify(parent_key),
}
parent = nested_fieldsets[parent_key]
if len(parts) == 3: # Top-level within fieldset
parent["fields"].append(field_name)
else:
sub_key = parts[2]
if not parent["fieldsets"].get(sub_key):
parent["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
parent["fieldsets"][sub_key]["fields"].append(field_name)
# Add nested fieldsets to fieldsets
for group in nested_fieldsets.values():
total_fields = 0
for fieldset in group["fieldsets"].values():
if (field_count := len(fieldset["fields"])) == 1:
group["fields"].append(fieldset["fields"][0])
fieldset["fields"] = []
else:
title = f"{group['title']}: {fieldset['title']}: "
for field in fieldset["fields"]:
self.strip_title(field, title)
total_fields += field_count
if (total_fields + len(group["fields"])) == 1:
others.append(group["fields"][0])
else:
title = f"{group['title']}: "
for field in group["fields"]:
self.strip_title(field, title)
fieldsets.append(group)
# Add 'others' tab if there are any fields
if others:
fieldsets.append({"title": "Others", "fields": others, "fieldsets": []})
return fieldsets
def get_nested_data(self):
"""
Builds the original nested JSON structure from flat form data.
Form fields are named with dot notation (e.g., 'spec.replicas')
"""
result = {}
for field_name, value in self.cleaned_data.items():
if value is None or value == "":
continue
parts = field_name.split(".")
current = result
# Navigate through the nested structure
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Last part, set the value
current[part] = value
else:
# Create nested dict if it doesn't exist
if part not in current:
current[part] = {}
current = current[part]
return result
def clean(self):
cleaned_data = super().clean()
self.validate_nested_data(
self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"]
)
return cleaned_data
def validate_nested_data(self, data, schema):
"""Validate data against the provided OpenAPI v3 schema"""
# TODO: actually validate the nested data.
# TODO: get jsonschema to give us a path to the failing field rather than just an error message,
# then add the validation error to that field (self.add_error())
# try:
# validate(instance=data, schema=schema)
# except Exception as e:
# raise forms.ValidationError(f"Validation error: {e.message}")
pass
def generate_model_form_class(model):

View file

@ -22,5 +22,6 @@ class Command(BaseCommand):
return
user.is_superuser = True
user.is_staff = True
user.save()
self.stdout.write(self.style.SUCCESS(f"{user} is now a superuser."))

View file

@ -1,9 +1,13 @@
# Generated by Django 5.2b1 on 2025-03-16 08:44
# Generated by Django 5.2b1 on 2025-03-31 09:20
import django.core.validators
import django.db.models.deletion
import encrypted_fields.fields
import rules.contrib.models
from django.conf import settings
from django.db import migrations, models
import servala.core.models.service
import servala.core.models.user
@ -11,9 +15,128 @@ class Migration(migrations.Migration):
initial = True
dependencies = []
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="BillingEntity",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"erp_reference",
models.CharField(
blank=True, max_length=100, verbose_name="ERP reference"
),
),
],
options={
"verbose_name": "Billing entity",
"verbose_name_plural": "Billing entities",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="CloudProvider",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"logo",
models.ImageField(
blank=True,
null=True,
upload_to="public/service_providers",
verbose_name="Logo",
),
),
(
"external_links",
models.JSONField(
blank=True, null=True, verbose_name="External links"
),
),
],
options={
"verbose_name": "Cloud provider",
"verbose_name_plural": "Cloud providers",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="OrganizationOrigin",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
],
options={
"verbose_name": "Organization origin",
"verbose_name_plural": "Organization origins",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="User",
fields=[
@ -33,6 +156,14 @@ class Migration(migrations.Migration):
blank=True, null=True, verbose_name="last login"
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
(
"email",
models.EmailField(
@ -73,105 +204,38 @@ class Migration(migrations.Migration):
verbose_name="Is superuser",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "User",
"verbose_name_plural": "Users",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
managers=[
("objects", servala.core.models.user.UserManager()),
],
),
migrations.CreateModel(
name="BillingEntity",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"erp_reference",
models.CharField(
blank=True, max_length=100, verbose_name="ERP reference"
),
),
],
options={
"verbose_name": "Billing entity",
"verbose_name_plural": "Billing entities",
},
),
migrations.CreateModel(
name="CloudProvider",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"logo",
models.ImageField(
blank=True,
null=True,
upload_to="public/service_providers",
verbose_name="Logo",
),
),
(
"external_links",
models.JSONField(
blank=True, null=True, verbose_name="External links"
),
),
],
options={
"verbose_name": "Cloud provider",
"verbose_name_plural": "Cloud providers",
},
),
migrations.CreateModel(
name="OrganizationOrigin",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
],
options={
"verbose_name": "Organization origin",
"verbose_name_plural": "Organization origins",
},
),
migrations.CreateModel(
name="ControlPlane",
fields=[
@ -184,16 +248,29 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"k8s_api_endpoint",
models.URLField(verbose_name="Kubernetes API endpoint"),
"api_credentials",
encrypted_fields.fields.EncryptedJSONField(
help_text="Required fields: certificate-authority-data, server (URL), token",
validators=[
servala.core.models.service.validate_api_credentials
],
verbose_name="API credentials",
),
),
("api_credentials", models.JSONField(verbose_name="API credentials")),
(
"cloud_provider",
models.ForeignKey(
@ -208,6 +285,7 @@ class Migration(migrations.Migration):
"verbose_name": "Control plane",
"verbose_name_plural": "Control planes",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="Organization",
@ -221,10 +299,35 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"namespace",
models.CharField(
help_text="This namespace will be used for all Kubernetes resources. Cannot be changed after creation.",
max_length=63,
unique=True,
validators=[
django.core.validators.RegexValidator(
code="invalid_kubernetes_name",
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
)
],
verbose_name="Kubernetes Namespace",
),
),
(
"billing_entity",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="organizations",
to="core.billingentity",
@ -236,6 +339,7 @@ class Migration(migrations.Migration):
"verbose_name": "Organization",
"verbose_name_plural": "Organizations",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="OrganizationMembership",
@ -249,6 +353,14 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
(
"date_joined",
models.DateTimeField(auto_now_add=True, verbose_name="Date joined"),
@ -289,6 +401,7 @@ class Migration(migrations.Migration):
"verbose_name": "Organization membership",
"verbose_name_plural": "Organization memberships",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.AddField(
model_name="organization",
@ -322,6 +435,14 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
@ -352,6 +473,7 @@ class Migration(migrations.Migration):
"verbose_name": "Service category",
"verbose_name_plural": "Service categories",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="Service",
@ -365,7 +487,21 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"slug",
models.SlugField(
max_length=100, unique=True, verbose_name="URL slug"
),
),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
@ -399,6 +535,57 @@ class Migration(migrations.Migration):
"verbose_name": "Service",
"verbose_name_plural": "Services",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="ServiceDefinition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"api_definition",
models.JSONField(
blank=True,
help_text="Contains group, version, and kind information",
null=True,
verbose_name="API Definition",
),
),
(
"service",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_definitions",
to="core.service",
verbose_name="Service",
),
),
],
options={
"verbose_name": "Service definition",
"verbose_name_plural": "Service definitions",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="ServiceOffering",
@ -413,16 +600,16 @@ class Migration(migrations.Migration):
),
),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"control_plane",
models.ManyToManyField(
related_name="offerings",
to="core.controlplane",
verbose_name="Control planes",
),
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"provider",
@ -447,6 +634,7 @@ class Migration(migrations.Migration):
"verbose_name": "Service offering",
"verbose_name_plural": "Service offerings",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="Plan",
@ -460,6 +648,14 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
@ -493,5 +689,142 @@ class Migration(migrations.Migration):
"verbose_name": "Plan",
"verbose_name_plural": "Plans",
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="ControlPlaneCRD",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
(
"control_plane",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="offering_connections",
to="core.controlplane",
verbose_name="Control plane",
),
),
(
"service_definition",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="offering_control_planes",
to="core.servicedefinition",
verbose_name="Service definition",
),
),
(
"service_offering",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="control_plane_connections",
to="core.serviceoffering",
verbose_name="Service offering",
),
),
],
options={
"verbose_name": "Service offering control plane connection",
"verbose_name_plural": "Service offering control planes connections",
"unique_together": {("service_offering", "control_plane")},
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="ServiceInstance",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
(
"name",
models.CharField(
max_length=63,
validators=[
django.core.validators.RegexValidator(
code="invalid_kubernetes_name",
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
)
],
verbose_name="Name",
),
),
("is_deleted", models.BooleanField(default=False)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"context",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="service_instances",
to="core.controlplanecrd",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"deleted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="service_instances",
to="core.organization",
verbose_name="Organization",
),
),
],
options={
"verbose_name": "Service instance",
"verbose_name_plural": "Service instances",
"unique_together": {("name", "organization", "context")},
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.2b1 on 2025-04-03 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="controlplanecrd",
options={
"verbose_name": "ControlPlane CRD",
"verbose_name_plural": "ControlPlane CRDs",
},
),
migrations.AddField(
model_name="controlplane",
name="required_label",
field=models.CharField(
blank=True,
help_text="Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane.",
max_length=100,
null=True,
verbose_name="Required Label",
),
),
]

View file

@ -1,89 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-17 06:19
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="billingentity",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="Created",
),
preserve_default=False,
),
migrations.AddField(
model_name="billingentity",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
migrations.AddField(
model_name="organization",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="Created",
),
preserve_default=False,
),
migrations.AddField(
model_name="organization",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
migrations.AddField(
model_name="organizationmembership",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="Created",
),
preserve_default=False,
),
migrations.AddField(
model_name="organizationmembership",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
migrations.AddField(
model_name="organizationorigin",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="Created",
),
preserve_default=False,
),
migrations.AddField(
model_name="organizationorigin",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
migrations.AddField(
model_name="user",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="Created",
),
preserve_default=False,
),
migrations.AddField(
model_name="user",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-20 08:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_billingentity_created_at_billingentity_updated_at_and_more"),
]
operations = [
migrations.AlterField(
model_name="organization",
name="billing_entity",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="organizations",
to="core.billingentity",
verbose_name="Billing entity",
),
),
]

View file

@ -1,46 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-24 06:33
import encrypted_fields.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("core", "0003_billing_entity_nullable"),
]
operations = [
migrations.AddField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
migrations.AddField(
model_name="user",
name="user_permissions",
field=models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
migrations.AlterField(
model_name="controlplane",
name="api_credentials",
field=encrypted_fields.fields.EncryptedJSONField(
verbose_name="API credentials"
),
),
]

View file

@ -1,29 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-24 10:27
import encrypted_fields.fields
from django.db import migrations
import servala.core.models.service
class Migration(migrations.Migration):
dependencies = [
("core", "0004_encrypt_api_credentials"),
]
operations = [
migrations.RemoveField(
model_name="controlplane",
name="k8s_api_endpoint",
),
migrations.AlterField(
model_name="controlplane",
name="api_credentials",
field=encrypted_fields.fields.EncryptedJSONField(
help_text="Required fields: certificate-authority-data, server (URL), token",
validators=[servala.core.models.service.validate_api_credentials],
verbose_name="API credentials",
),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-24 14:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_remove_controlplane_k8s_api_endpoint"),
]
operations = [
migrations.AddField(
model_name="service",
name="slug",
field=models.SlugField(
default="slug", max_length=100, unique=True, verbose_name="URL slug"
),
preserve_default=False,
),
]

View file

@ -1,115 +0,0 @@
# Generated by Django 5.2b1 on 2025-03-25 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0006_service_slug"),
]
operations = [
migrations.RemoveField(
model_name="serviceoffering",
name="control_plane",
),
migrations.CreateModel(
name="ServiceDefinition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="Name")),
(
"description",
models.TextField(blank=True, verbose_name="Description"),
),
(
"api_definition",
models.JSONField(
blank=True,
help_text="Contains group, version, and kind information",
null=True,
verbose_name="API Definition",
),
),
(
"service",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_definitions",
to="core.service",
verbose_name="Service",
),
),
],
options={
"verbose_name": "Service definition",
"verbose_name_plural": "Service definitions",
},
),
migrations.CreateModel(
name="ServiceOfferingControlPlane",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"control_plane",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="offering_connections",
to="core.controlplane",
verbose_name="Control plane",
),
),
(
"service_definition",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="offering_control_planes",
to="core.servicedefinition",
verbose_name="Service definition",
),
),
(
"service_offering",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="control_plane_connections",
to="core.serviceoffering",
verbose_name="Service offering",
),
),
],
options={
"verbose_name": "Service offering control plane connection",
"verbose_name_plural": "Service offering control planes connections",
"unique_together": {("service_offering", "control_plane")},
},
),
migrations.AddField(
model_name="serviceoffering",
name="control_planes",
field=models.ManyToManyField(
related_name="offerings",
through="core.ServiceOfferingControlPlane",
to="core.controlplane",
verbose_name="Control planes",
),
),
]

View file

@ -8,12 +8,13 @@ from .organization import (
from .service import (
CloudProvider,
ControlPlane,
ControlPlaneCRD,
Plan,
Service,
ServiceCategory,
ServiceDefinition,
ServiceInstance,
ServiceOffering,
ServiceOfferingControlPlane,
)
from .user import User
@ -21,6 +22,7 @@ __all__ = [
"BillingEntity",
"CloudProvider",
"ControlPlane",
"ControlPlaneCRD",
"Organization",
"OrganizationMembership",
"OrganizationOrigin",
@ -28,8 +30,8 @@ __all__ = [
"Plan",
"Service",
"ServiceCategory",
"ServiceInstance",
"ServiceDefinition",
"ServiceOffering",
"ServiceOfferingControlPlane",
"User",
]

View file

@ -9,10 +9,20 @@ from django_scopes import ScopedManager, scopes_disabled
from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator
class Organization(ServalaModelMixin, models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name"))
namespace = models.CharField(
max_length=63,
verbose_name=_("Kubernetes Namespace"),
unique=True,
help_text=_(
"This namespace will be used for all Kubernetes resources. Cannot be changed after creation."
),
validators=[kubernetes_name_validator],
)
billing_entity = models.ForeignKey(
to="BillingEntity",
@ -39,6 +49,7 @@ class Organization(ServalaModelMixin, models.Model):
base = "/org/{self.slug}/"
details = "{base}details/"
services = "{base}services/"
instances = "{base}instances/"
@cached_property
def slug(self):

View file

@ -1,15 +1,22 @@
import json
import kubernetes
import urlman
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models
from django.db import IntegrityError, models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from kubernetes import config
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator
class ServiceCategory(models.Model):
class ServiceCategory(ServalaModelMixin, models.Model):
"""
Categories for services, e.g. "Databases", "Storage", "Compute".
"""
@ -40,7 +47,7 @@ class ServiceCategory(models.Model):
return self.name
class Service(models.Model):
class Service(ServalaModelMixin, models.Model):
"""
A service that can be offered, e.g. "PostgreSQL", "MinIO", "Kubernetes".
"""
@ -89,7 +96,11 @@ def validate_api_credentials(value):
return validate_dict(value, required_fields)
class ControlPlane(models.Model):
class ControlPlane(ServalaModelMixin, models.Model):
"""
Note: ControlPlanes are called "Service Provider Zone" in the user-facing frontend.
"""
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
# Either contains the fields "certificate_authority_data", "server" and "token", or is empty
@ -98,6 +109,15 @@ class ControlPlane(models.Model):
help_text="Required fields: certificate-authority-data, server (URL), token",
validators=[validate_api_credentials],
)
required_label = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Required Label"),
help_text=_(
"Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane."
),
)
cloud_provider = models.ForeignKey(
to="CloudProvider",
@ -113,6 +133,12 @@ class ControlPlane(models.Model):
def __str__(self):
return self.name
@property
def service_definitions(self):
return ServiceDefinition.objects.filter(
offering_control_planes__control_plane=self
).distinct()
@property
def kubernetes_config(self):
conf = kubernetes.client.Configuration()
@ -176,8 +202,23 @@ class ControlPlane(models.Model):
except Exception as e:
return False, _("Connection error: {}").format(str(e))
def get_or_create_namespace(self, name):
api_instance = kubernetes.client.CoreV1Api(self.get_kubernetes_client())
try:
api_instance.read_namespace(name=name)
except kubernetes.client.ApiException as e:
if e.status == 404:
# Namespace does not exist, create it
body = kubernetes.client.V1Namespace(
metadata=kubernetes.client.V1ObjectMeta(name=name)
)
api_instance.create_namespace(body=body)
else:
# If there's another error, raise it
raise
class CloudProvider(models.Model):
class CloudProvider(ServalaModelMixin, models.Model):
"""
A cloud provider, e.g. "Exoscale".
"""
@ -202,7 +243,7 @@ class CloudProvider(models.Model):
return self.name
class Plan(models.Model):
class Plan(ServalaModelMixin, models.Model):
"""
Each service offering can have multiple plans, e.g. for different tiers.
"""
@ -236,7 +277,7 @@ def validate_api_definition(value):
return validate_dict(value, required_fields)
class ServiceDefinition(models.Model):
class ServiceDefinition(ServalaModelMixin, models.Model):
"""
Configuration/service implementation: contains information on which
CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
@ -279,10 +320,10 @@ class ServiceDefinition(models.Model):
return self.name
class ServiceOfferingControlPlane(models.Model):
class ControlPlaneCRD(ServalaModelMixin, models.Model):
"""
Each combination of ServiceOffering and ControlPlane can have a different
ServiceDefinition, which is here modeled as the "through" model.
ServiceDefinition, which is here modeled as basically a "through" model.
"""
service_offering = models.ForeignKey(
@ -305,8 +346,8 @@ class ServiceOfferingControlPlane(models.Model):
)
class Meta:
verbose_name = _("Service offering control plane connection")
verbose_name_plural = _("Service offering control planes connections")
verbose_name = _("ControlPlane CRD")
verbose_name_plural = _("ControlPlane CRDs")
unique_together = [("service_offering", "control_plane")]
def __str__(self):
@ -365,7 +406,7 @@ class ServiceOfferingControlPlane(models.Model):
return generate_model_form_class(self.django_model)
class ServiceOffering(models.Model):
class ServiceOffering(ServalaModelMixin, models.Model):
"""
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
"""
@ -382,12 +423,6 @@ class ServiceOffering(models.Model):
related_name="offerings",
verbose_name=_("Provider"),
)
control_planes = models.ManyToManyField(
to="ControlPlane",
through="ServiceOfferingControlPlane",
related_name="offerings",
verbose_name=_("Control planes"),
)
description = models.TextField(blank=True, verbose_name=_("Description"))
class Meta:
@ -398,3 +433,120 @@ class ServiceOffering(models.Model):
return _("{service_name} at {provider_name}").format(
service_name=self.service.name, provider_name=self.provider.name
)
@property
def control_planes(self):
return ControlPlane.objects.filter(
offering_connections__service_offering=self
).distinct()
class ServiceInstance(ServalaModelMixin, models.Model):
"""
The source of truth for service instances is Kubernetes.
The Django model only contains metadata, all other information is queried
on the fly.
"""
name = models.CharField(
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
)
organization = models.ForeignKey(
to="core.Organization",
on_delete=models.PROTECT,
verbose_name=_("Organization"),
related_name="service_instances",
)
created_by = models.ForeignKey(
to="core.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
context = models.ForeignKey(
to="core.ControlPlaneCRD",
related_name="service_instances",
on_delete=models.PROTECT,
)
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
to="core.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
class Meta:
verbose_name = _("Service instance")
verbose_name_plural = _("Service instances")
# Names are unique per de-facto namespace, which is defined by the
# Organization + ServiceDefinition (group, version) + the ControlPlane.
unique_together = [("name", "organization", "context")]
class urls(urlman.Urls):
base = "{self.organization.urls.instances}{self.name}/"
@classmethod
def create_instance(cls, name, organization, context, created_by, spec_data):
# Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization.namespace)
try:
instance = cls.objects.create(
name=name,
organization=organization,
created_by=created_by,
context=context,
)
except IntegrityError:
raise ValidationError(
_(
"An instance with this name already exists in this organization. Please choose a different name."
)
)
try:
group = context.service_definition.api_definition["group"]
version = context.service_definition.api_definition["version"]
kind = context.service_definition.api_definition["kind"]
create_data = {
"apiVersion": f"{group}/{version}",
"kind": kind,
"metadata": {
"name": name,
"namespace": organization.namespace,
},
"spec": spec_data or {},
}
if label := context.control_plane.required_label:
create_data["metadata"]["labels"] = [
{settings.DEFAULT_LABEL_KEY: label}
]
api_instance = client.CustomObjectsApi(
context.control_plane.get_kubernetes_client()
)
plural = kind.lower()
if not plural.endswith("s"):
plural = f"{plural}s"
api_instance.create_namespaced_custom_object(
group=group,
version=version,
namespace=organization.namespace,
plural=plural,
body=create_data,
)
except Exception as e:
instance.delete()
if isinstance(e, ApiException):
try:
error_body = json.loads(e.body)
reason = error_body.get("message", str(e))
raise ValidationError(_("Kubernetes API error: {}").format(reason))
except (ValueError, TypeError):
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
raise ValidationError(_("Error creating instance: {}").format(str(e)))
return instance

View file

@ -0,0 +1,12 @@
from django.core.validators import RegexValidator
from django.utils.translation import gettext_lazy as _
# Kubernetes resource name validator - follows the same rules as namespace names
kubernetes_name_validator = RegexValidator(
regex=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
message=_(
'Name must consist of lowercase alphanumeric characters or "-", '
"must start and end with an alphanumeric character."
),
code="invalid_kubernetes_name",
)

View file

@ -1,4 +1,6 @@
from django import forms
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from servala.core.models import Organization
from servala.frontend.forms.mixins import HtmxMixin
@ -7,4 +9,19 @@ from servala.frontend.forms.mixins import HtmxMixin
class OrganizationForm(HtmxMixin, ModelForm):
class Meta:
model = Organization
fields = ("name",)
fields = ("name", "namespace")
widgets = {
"namespace": forms.TextInput(
attrs={
"pattern": "[a-z0-9]([-a-z0-9]*[a-z0-9])?",
"title": _(
'Lowercase alphanumeric characters or "-", must start and end with alphanumeric'
),
}
)
}
help_texts = {
"namespace": _(
"This namespace will be used for all resources and cannot be changed later."
)
}

View file

@ -17,15 +17,17 @@ class VerticalFormRenderer(TemplatesSetting):
form_template_name = "frontend/forms/form.html"
field_template_name = "frontend/forms/vertical_field.html"
def get_class_names(self, input_type):
def get_class_names(self, field):
input_type = self.get_field_input_type(field)
errors = "is-invalid " if field.errors else ""
if input_type == "checkbox":
return "form-check-input"
return "form-control"
return f"{errors}form-check-input"
return f"{errors}form-control"
def get_widget_input_type(self, widget):
if isinstance(widget, Textarea):
return "textarea"
return widget.input_type
return getattr(widget, "input_type", None)
def get_field_input_type(self, field):
widget = field.field.widget
@ -35,9 +37,8 @@ class VerticalFormRenderer(TemplatesSetting):
def render(self, template_name, context, request=None):
if field := context.get("field"):
input_type = self.get_field_input_type(field)
field.build_widget_attrs = inject_class(
field.build_widget_attrs, self.get_class_names(input_type)
field.build_widget_attrs, self.get_class_names(field)
)
return super().render(template_name, context, request)

View file

@ -1,4 +1,5 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from servala.core.models import CloudProvider, ControlPlane, ServiceCategory
@ -23,7 +24,11 @@ class ServiceFilterForm(forms.Form):
class ControlPlaneSelectForm(forms.Form):
control_plane = forms.ModelChoiceField(queryset=ControlPlane.objects.none())
control_plane = forms.ModelChoiceField(
queryset=ControlPlane.objects.none(),
label=_("Service Provider Zone"),
empty_label=None,
)
def __init__(self, *args, planes=None, **kwargs):
super().__init__(*args, **kwargs)

View file

@ -1,6 +1,6 @@
{% load i18n %}
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}">
{% if not hide_label %}
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if field.errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}">
{% if not hide_label and not field.is_hidden %}
{% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %}
<label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label>
{% endif %}
@ -9,7 +9,7 @@
<fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
{% endif %}
{{ field }}
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %}
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected and not field.is_hidden %}
<label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label>
{% endif %}
{% if field.use_fieldset %}</fieldset>{% endif %}

View file

@ -1,12 +1,17 @@
{% if errors %}
{% load i18n %}
{% if form.non_field_errors or form.errors %}
<div class="alert alert-danger" role="alert">
<div>
{% if errors|length > 1 %}
<ul>
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% if form.non_field_errors %}
{% if form.non_field_errors|length > 1 %}
<ul>
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% else %}
{{ form.non_field_errors.0 }}
{% endif %}
{% else %}
{{ errors.0 }}
{% translate "We could not save your changes." %}
{% endif %}
</div>
</div>

View file

@ -17,7 +17,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/form.html" with form=service_form %}
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
{% endif %}
</div>
</div>

View file

@ -46,6 +46,15 @@
</th>
{% partial org-name %}
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
</th>
<td>
<div>{{ form.instance.namespace }}</div>
<small class="text-muted">{% translate "The namespace cannot be changed after creation." %}</small>
</td>
</tr>
</tbody>
</table>
</div>

View file

@ -2,7 +2,6 @@
<form class="form form-vertical"
method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% include "includes/form_errors.html" %}
{% csrf_token %}
{{ form }}
{% if extra_field %}{{ extra_field }}{% endif %}

View file

@ -1,18 +0,0 @@
{% load i18n %}
{% if form.non_field_errors or form.errors %}
<div class="alert alert-danger" role="alert">
<div>
{% if form.non_field_errors %}
{% if form.non_field_errors|length > 1 %}
<ul>
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% else %}
{{ form.non_field_errors.0 }}
{% endif %}
{% else %}
{% translate "We could not save your changes." %}
{% endif %}
</div>
</div>
{% endif %}

View file

@ -0,0 +1,52 @@
{% load i18n %}
{% load get_field %}
<form class="form form-vertical"
method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if forloop.first %}show active{% endif %}"
id="{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets.values %}
{% if subfieldset.fields %}
<h4>{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
<div class="col-sm-12 d-flex justify-content-end">
<button class="btn btn-primary me-1 mb-1" type="submit">
{% if form_submit_label %}
{{ form_submit_label }}
{% else %}
{% translate "Save" %}
{% endif %}
</button>
</div>
</form>

View file

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter
def get_field(form, field_name):
return form[field_name]

View file

@ -1,7 +1,15 @@
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView
from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane
from servala.core.models import (
ControlPlaneCRD,
Service,
ServiceInstance,
ServiceOffering,
)
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
@ -68,26 +76,70 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
data = None
if "control_plane" in self.request.GET:
data = self.request.GET
elif self.request.method == "POST" and self.context_object:
data = {"control_plane": self.context_object.control_plane_id}
return ControlPlaneSelectForm(data=data, planes=self.planes)
@cached_property
def selected_plane(self):
if self.select_form.data and self.select_form.is_valid():
return self.select_form.cleaned_data["control_plane"]
field = self.select_form.fields["control_plane"]
return field.initial or field.queryset.first()
@cached_property
def context_object(self):
if self.request.method == "POST":
return ControlPlaneCRD.objects.filter(
pk=self.request.POST.get("context"),
# Make sure we dont use a malicious ID
control_plane__in=self.planes,
).first()
return ControlPlaneCRD.objects.filter(
control_plane=self.selected_plane, service_offering=self.object
).first()
def get_instance_form(self):
return self.context_object.model_form_class(
data=self.request.POST if self.request.method == "POST" else None,
initial={
"organization": self.request.organization,
"context": self.context_object,
},
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists()
if "control_plane" in self.request.GET:
if self.select_form.is_valid():
context["selected_plane"] = self.select_form.cleaned_data[
"control_plane"
]
try:
so_cp = ServiceOfferingControlPlane.objects.filter(
control_plane=self.select_form.cleaned_data["control_plane"],
service_offering=self.object,
).first()
if not so_cp:
context["form_error"] = True
except Exception:
context["form_error"] = True
else:
context["service_form"] = so_cp.model_form_class()
context["selected_plane"] = self.selected_plane
context["service_form"] = self.get_instance_form()
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
if not self.context_object:
context["form_error"] = True
return self.render_to_response(context)
form = self.get_instance_form()
if form.is_valid():
try:
service_instance = ServiceInstance.create_instance(
organization=self.organization,
name=form.cleaned_data["name"],
context=self.context_object,
created_by=request.user,
spec_data=form.get_nested_data().get("spec"),
)
return redirect(service_instance.urls.base)
except ValidationError as e:
messages.error(self.request, e.message or str(e))
except Exception as e:
messages.error(self.request, str(e))
# If the form is not valid or if the service creation failed, we render it again
context["service_form"] = form
return self.render_to_response(context)

View file

@ -210,6 +210,7 @@ LANGUAGE_COOKIE_NAME = "servala_lang"
SESSION_COOKIE_NAME = "servala_sess"
SESSION_COOKIE_SECURE = not DEBUG
DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# TODO

View file

@ -15,12 +15,16 @@ def origin():
@pytest.fixture
def organization(origin):
return Organization.objects.create(name="Test Org", origin=origin)
return Organization.objects.create(
name="Test Org", namespace="test-org", origin=origin
)
@pytest.fixture
def other_organization(origin):
return Organization.objects.create(name="Test Org Alternate", origin=origin)
return Organization.objects.create(
name="Test Org Alternate", namespace="test-org-alternate", origin=origin
)
@pytest.fixture

99
uv.lock generated
View file

@ -480,6 +480,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049 },
]
[[package]]
name = "jsonschema"
version = "4.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 },
]
[[package]]
name = "jsonschema-specifications"
version = "2024.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 },
]
[[package]]
name = "kubernetes"
version = "32.0.1"
@ -769,6 +796,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
]
[[package]]
name = "referencing"
version = "0.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
]
[[package]]
name = "regex"
version = "2024.11.6"
@ -835,6 +876,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]]
name = "rpds-py"
version = "0.24.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/b3/52b213298a0ba7097c7ea96bee95e1947aa84cc816d48cebb539770cdf41/rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", size = 26863 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/e0/1c55f4a3be5f1ca1a4fd1f3ff1504a1478c1ed48d84de24574c4fa87e921/rpds_py-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205", size = 366945 },
{ url = "https://files.pythonhosted.org/packages/39/1b/a3501574fbf29118164314dbc800d568b8c1c7b3258b505360e8abb3902c/rpds_py-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7", size = 351935 },
{ url = "https://files.pythonhosted.org/packages/dc/47/77d3d71c55f6a374edde29f1aca0b2e547325ed00a9da820cabbc9497d2b/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9", size = 390817 },
{ url = "https://files.pythonhosted.org/packages/4e/ec/1e336ee27484379e19c7f9cc170f4217c608aee406d3ae3a2e45336bff36/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e", size = 401983 },
{ url = "https://files.pythonhosted.org/packages/07/f8/39b65cbc272c635eaea6d393c2ad1ccc81c39eca2db6723a0ca4b2108fce/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda", size = 451719 },
{ url = "https://files.pythonhosted.org/packages/32/05/05c2b27dd9c30432f31738afed0300659cb9415db0ff7429b05dfb09bbde/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e", size = 442546 },
{ url = "https://files.pythonhosted.org/packages/7d/e0/19383c8b5d509bd741532a47821c3e96acf4543d0832beba41b4434bcc49/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029", size = 393695 },
{ url = "https://files.pythonhosted.org/packages/9d/15/39f14e96d94981d0275715ae8ea564772237f3fa89bc3c21e24de934f2c7/rpds_py-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9", size = 427218 },
{ url = "https://files.pythonhosted.org/packages/22/b9/12da7124905a680f690da7a9de6f11de770b5e359f5649972f7181c8bf51/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7", size = 568062 },
{ url = "https://files.pythonhosted.org/packages/88/17/75229017a2143d915f6f803721a6d721eca24f2659c5718a538afa276b4f/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91", size = 596262 },
{ url = "https://files.pythonhosted.org/packages/aa/64/8e8a1d8bd1b6b638d6acb6d41ab2cec7f2067a5b8b4c9175703875159a7c/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56", size = 564306 },
{ url = "https://files.pythonhosted.org/packages/68/1c/a7eac8d8ed8cb234a9b1064647824c387753343c3fab6ed7c83481ed0be7/rpds_py-0.24.0-cp312-cp312-win32.whl", hash = "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30", size = 224281 },
{ url = "https://files.pythonhosted.org/packages/bb/46/b8b5424d1d21f2f2f3f2d468660085318d4f74a8df8289e3dd6ad224d488/rpds_py-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034", size = 239719 },
{ url = "https://files.pythonhosted.org/packages/9d/c3/3607abc770395bc6d5a00cb66385a5479fb8cd7416ddef90393b17ef4340/rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c", size = 367072 },
{ url = "https://files.pythonhosted.org/packages/d8/35/8c7ee0fe465793e3af3298dc5a9f3013bd63e7a69df04ccfded8293a4982/rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c", size = 351919 },
{ url = "https://files.pythonhosted.org/packages/91/d3/7e1b972501eb5466b9aca46a9c31bcbbdc3ea5a076e9ab33f4438c1d069d/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240", size = 390360 },
{ url = "https://files.pythonhosted.org/packages/a2/a8/ccabb50d3c91c26ad01f9b09a6a3b03e4502ce51a33867c38446df9f896b/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8", size = 400704 },
{ url = "https://files.pythonhosted.org/packages/53/ae/5fa5bf0f3bc6ce21b5ea88fc0ecd3a439e7cb09dd5f9ffb3dbe1b6894fc5/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8", size = 450839 },
{ url = "https://files.pythonhosted.org/packages/e3/ac/c4e18b36d9938247e2b54f6a03746f3183ca20e1edd7d3654796867f5100/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b", size = 441494 },
{ url = "https://files.pythonhosted.org/packages/bf/08/b543969c12a8f44db6c0f08ced009abf8f519191ca6985509e7c44102e3c/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d", size = 393185 },
{ url = "https://files.pythonhosted.org/packages/da/7e/f6eb6a7042ce708f9dfc781832a86063cea8a125bbe451d663697b51944f/rpds_py-0.24.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7", size = 426168 },
{ url = "https://files.pythonhosted.org/packages/38/b0/6cd2bb0509ac0b51af4bb138e145b7c4c902bb4b724d6fd143689d6e0383/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad", size = 567622 },
{ url = "https://files.pythonhosted.org/packages/64/b0/c401f4f077547d98e8b4c2ec6526a80e7cb04f519d416430ec1421ee9e0b/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120", size = 595435 },
{ url = "https://files.pythonhosted.org/packages/9f/ec/7993b6e803294c87b61c85bd63e11142ccfb2373cf88a61ec602abcbf9d6/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9", size = 563762 },
{ url = "https://files.pythonhosted.org/packages/1f/29/4508003204cb2f461dc2b83dd85f8aa2b915bc98fe6046b9d50d4aa05401/rpds_py-0.24.0-cp313-cp313-win32.whl", hash = "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143", size = 223510 },
{ url = "https://files.pythonhosted.org/packages/f9/12/09e048d1814195e01f354155fb772fb0854bd3450b5f5a82224b3a319f0e/rpds_py-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a", size = 239075 },
{ url = "https://files.pythonhosted.org/packages/d2/03/5027cde39bb2408d61e4dd0cf81f815949bb629932a6c8df1701d0257fc4/rpds_py-0.24.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114", size = 362974 },
{ url = "https://files.pythonhosted.org/packages/bf/10/24d374a2131b1ffafb783e436e770e42dfdb74b69a2cd25eba8c8b29d861/rpds_py-0.24.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405", size = 348730 },
{ url = "https://files.pythonhosted.org/packages/7a/d1/1ef88d0516d46cd8df12e5916966dbf716d5ec79b265eda56ba1b173398c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47", size = 387627 },
{ url = "https://files.pythonhosted.org/packages/4e/35/07339051b8b901ecefd449ebf8e5522e92bcb95e1078818cbfd9db8e573c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272", size = 394094 },
{ url = "https://files.pythonhosted.org/packages/dc/62/ee89ece19e0ba322b08734e95441952062391065c157bbd4f8802316b4f1/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd", size = 449639 },
{ url = "https://files.pythonhosted.org/packages/15/24/b30e9f9e71baa0b9dada3a4ab43d567c6b04a36d1cb531045f7a8a0a7439/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a", size = 438584 },
{ url = "https://files.pythonhosted.org/packages/28/d9/49f7b8f3b4147db13961e19d5e30077cd0854ccc08487026d2cb2142aa4a/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d", size = 391047 },
{ url = "https://files.pythonhosted.org/packages/49/b0/e66918d0972c33a259ba3cd7b7ff10ed8bd91dbcfcbec6367b21f026db75/rpds_py-0.24.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7", size = 418085 },
{ url = "https://files.pythonhosted.org/packages/e1/6b/99ed7ea0a94c7ae5520a21be77a82306aac9e4e715d4435076ead07d05c6/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d", size = 564498 },
{ url = "https://files.pythonhosted.org/packages/28/26/1cacfee6b800e6fb5f91acecc2e52f17dbf8b0796a7c984b4568b6d70e38/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797", size = 590202 },
{ url = "https://files.pythonhosted.org/packages/a9/9e/57bd2f9fba04a37cef673f9a66b11ca8c43ccdd50d386c455cd4380fe461/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c", size = 561771 },
{ url = "https://files.pythonhosted.org/packages/9f/cf/b719120f375ab970d1c297dbf8de1e3c9edd26fe92c0ed7178dd94b45992/rpds_py-0.24.0-cp313-cp313t-win32.whl", hash = "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba", size = 221195 },
{ url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 },
]
[[package]]
name = "rsa"
version = "4.9"
@ -868,6 +956,7 @@ dependencies = [
{ name = "django-fernet-encrypted-fields" },
{ name = "django-scopes" },
{ name = "django-template-partials" },
{ name = "jsonschema" },
{ name = "kubernetes" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
@ -900,6 +989,7 @@ requires-dist = [
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
{ name = "django-scopes", specifier = ">=2.0.0" },
{ name = "django-template-partials", specifier = ">=24.4" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "kubernetes", specifier = ">=32.0.1" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
@ -953,6 +1043,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
]
[[package]]
name = "typing-extensions"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 },
]
[[package]]
name = "tzdata"
version = "2025.1"