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") diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 6fc4219..a164b1a 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -1,10 +1,28 @@ 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): """ @@ -14,6 +32,8 @@ def generate_django_model(schema, group, version, kind): # defaults = {"apiVersion": f"{group}/{version}", "kind": kind} model_fields = {"__module__": "crd_models"} + 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"}) @@ -110,8 +130,35 @@ 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 + + for field in ("organization", "context"): + self.fields[field].widget = forms.HiddenInput() + + 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): 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/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/__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", 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 04a6f8f..ef6f9c9 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 @@ -17,9 +18,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 +415,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, @@ -452,3 +453,16 @@ 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}/" + + @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/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..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 @@ -68,26 +75,67 @@ 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 + + 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)