From b6260b4e9e1741d2dee88c58c2f5a5ada33b6da5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 11:29:43 +0100 Subject: [PATCH 1/9] Add missing migration --- .../core/migrations/0010_service_instance.py | 117 ++++++++++++++++++ src/servala/core/models/__init__.py | 2 + 2 files changed, 119 insertions(+) create mode 100644 src/servala/core/migrations/0010_service_instance.py diff --git a/src/servala/core/migrations/0010_service_instance.py b/src/servala/core/migrations/0010_service_instance.py new file mode 100644 index 0000000..9989650 --- /dev/null +++ b/src/servala/core/migrations/0010_service_instance.py @@ -0,0 +1,117 @@ +# Generated by Django 5.2b1 on 2025-03-28 10:29 + +import django.core.validators +import django.db.models.deletion +import rules.contrib.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0009_organization_namespace"), + ] + + operations = [ + migrations.AlterField( + model_name="organization", + name="namespace", + field=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", + ), + ), + migrations.AlterField( + model_name="servicecategory", + name="name", + field=models.CharField( + max_length=100, + 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", + ), + ), + 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=100, 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.serviceofferingcontrolplane", + ), + ), + ( + "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), + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 6d5b24d..722aabd 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -12,6 +12,7 @@ from .service import ( Service, ServiceCategory, ServiceDefinition, + ServiceInstance, ServiceOffering, ServiceOfferingControlPlane, ) @@ -28,6 +29,7 @@ __all__ = [ "Plan", "Service", "ServiceCategory", + "ServiceInstance", "ServiceDefinition", "ServiceOffering", "ServiceOfferingControlPlane", From 57945c8e515c5c51de665c6e999c4f4e6479971a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 11:47:20 +0100 Subject: [PATCH 2/9] Add ServiceInstance to admin --- src/servala/core/admin.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 66a4b30..454be56 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -13,6 +13,7 @@ from servala.core.models import ( Service, ServiceCategory, ServiceDefinition, + ServiceInstance, ServiceOffering, ServiceOfferingControlPlane, User, @@ -220,6 +221,41 @@ class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin): 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") From 8a1f72b317372bda94878d3dda657a9aa8cb4327 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:23:03 +0100 Subject: [PATCH 3/9] Add name, org and context to all crd forms --- src/servala/core/crd.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 6fc4219..b66fef8 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,10 +1,13 @@ 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 generate_django_model(schema, group, version, kind): """ @@ -14,6 +17,8 @@ def generate_django_model(schema, group, version, kind): # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} model_fields = {"__module__": "crd_models"} + for field in ("name", "organization", "context"): + model_fields[field] = ServiceInstance._meta.get_field(field) model_fields.update(build_object_fields(spec, "spec")) meta_class = type("Meta", (), {"app_label": "crd_models"}) @@ -110,6 +115,9 @@ def get_django_field( class CrdModelFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + for field in ("organization", "context"): + self.fields[field].widget = forms.HiddenInput() # self.fields["apiVersion"].disabled = True # self.fields["kind"].disabled = True From 172bdd7261ec4422d1ab85bbeec6902edff75253 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:43:21 +0100 Subject: [PATCH 4/9] Start offering page with visible form --- src/servala/frontend/forms/service.py | 4 ++- src/servala/frontend/views/service.py | 48 ++++++++++++++++++--------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index afef88c..3b7a547 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -25,7 +25,9 @@ class ServiceFilterForm(forms.Form): class ControlPlaneSelectForm(forms.Form): control_plane = forms.ModelChoiceField( - queryset=ControlPlane.objects.none(), label=_("Service Provider Zone") + queryset=ControlPlane.objects.none(), + label=_("Service Provider Zone"), + empty_label=None, ) def __init__(self, *args, planes=None, **kwargs): diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 00b7edd..f1a6357 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -68,26 +68,42 @@ 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 ServiceOfferingControlPlane.objects.filter( + pk=self.request.POST.get("context"), + # Make sure we don’t use a malicious ID + control_plane__in=self.planes, + ).first() + return ServiceOfferingControlPlane.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 From 33b82af67dd6cd42094d67f6b90e07315b06a7c1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:52:03 +0100 Subject: [PATCH 5/9] Use name validator on correct model, oops --- ...011_alter_servicecategory_name_and_more.py | 34 +++++++++++++++++++ src/servala/core/models/service.py | 8 ++--- 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py diff --git a/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py b/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py new file mode 100644 index 0000000..859e261 --- /dev/null +++ b/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2b1 on 2025-03-28 11:51 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0010_service_instance"), + ] + + operations = [ + migrations.AlterField( + model_name="servicecategory", + name="name", + field=models.CharField(max_length=100, verbose_name="Name"), + ), + migrations.AlterField( + model_name="serviceinstance", + name="name", + field=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", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 04a6f8f..b667ea7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -17,9 +17,7 @@ class ServiceCategory(ServalaModelMixin, models.Model): Categories for services, e.g. "Databases", "Storage", "Compute". """ - name = models.CharField( - max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator] - ) + name = models.CharField(max_length=100, verbose_name=_("Name")) description = models.TextField(blank=True, verbose_name=_("Description")) logo = models.ImageField( upload_to="public/service_categories", @@ -416,7 +414,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): on the fly. """ - name = models.CharField(max_length=100, verbose_name=_("Name")) + name = models.CharField( + max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] + ) organization = models.ForeignKey( to="core.Organization", on_delete=models.PROTECT, From 2f607f8271bb5b8303a7ed9b1e414b28287521ae Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:53:10 +0100 Subject: [PATCH 6/9] Proper django field duplication --- src/servala/core/crd.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index b66fef8..27952ed 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -9,6 +9,21 @@ 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. @@ -17,8 +32,8 @@ def generate_django_model(schema, group, version, kind): # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} model_fields = {"__module__": "crd_models"} - for field in ("name", "organization", "context"): - model_fields[field] = ServiceInstance._meta.get_field(field) + for field_name in ("name", "organization", "context"): + model_fields[field_name] = duplicate_field(field_name, ServiceInstance) model_fields.update(build_object_fields(spec, "spec")) meta_class = type("Meta", (), {"app_label": "crd_models"}) From 174837a87053167ad10e5b300e409a885462dca3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:57:02 +0100 Subject: [PATCH 7/9] Restructure form data into JSON object --- src/servala/core/crd.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 27952ed..a164b1a 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -133,8 +133,32 @@ class CrdModelFormMixin: for field in ("organization", "context"): self.fields[field].widget = forms.HiddenInput() - # self.fields["apiVersion"].disabled = True - # self.fields["kind"].disabled = True + + 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 generate_model_form_class(model): From 4e44b283b178ff6638395e010cd1a000acc1158a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:10:23 +0100 Subject: [PATCH 8/9] Add success url to instance model --- src/servala/core/models/organization.py | 1 + src/servala/core/models/service.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index a3c9a15..e3c2bf1 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -49,6 +49,7 @@ class Organization(ServalaModelMixin, models.Model): base = "/org/{self.slug}/" details = "{base}details/" services = "{base}services/" + services = "{base}instances/" @cached_property def slug(self): diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index b667ea7..301f82c 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,4 +1,5 @@ import kubernetes +import urlman from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models @@ -452,3 +453,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): # 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}/" + From 6e644dfe4414b4faec02e332b847122f9e4225d0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:11:17 +0100 Subject: [PATCH 9/9] Defer instance creation to model --- src/servala/core/models/service.py | 9 +++++++ src/servala/frontend/views/service.py | 34 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 301f82c..ef6f9c9 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -457,3 +457,12 @@ class ServiceInstance(ServalaModelMixin, models.Model): class urls(urlman.Urls): base = "{self.organization.urls.instances}{self.name}/" + @classmethod + def create_instance(cls, organization, context, created_by, spec_data): + name = spec_data.get("spec.name") + return cls.objects.create( + name=name, + organization=organization, + created_by=created_by, + context=context, + ) diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index f1a6357..e99e5a8 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,7 +1,14 @@ +from django.contrib import messages +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 ( + Service, + ServiceInstance, + ServiceOffering, + ServiceOfferingControlPlane, +) from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin @@ -107,3 +114,28 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView 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, + context=self.context_object, + created_by=request.user, + spec_data=form.get_nested_data(), + ) + return redirect(service_instance.urls.base) + 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)