From 13c6ca92531b30d54242dfe8d2e765dc28f4ffe7 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 09:00:34 +0100 Subject: [PATCH 01/37] Rename control plane selector to "service provider zone" --- src/servala/core/models/service.py | 3 +++ src/servala/frontend/forms/service.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index c4d2b79..f44e9e6 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -90,6 +90,9 @@ def validate_api_credentials(value): class ControlPlane(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 diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 0afb1b5..afef88c 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -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,9 @@ 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") + ) def __init__(self, *args, planes=None, **kwargs): super().__init__(*args, **kwargs) -- 2.47.2 From 2f127e94f7db1d8dd8b622c66b82334ca461e8fe Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 09:41:46 +0100 Subject: [PATCH 02/37] Add missing created_at/updated_at fields --- .../migrations/0008_created_and_updated.py | 134 ++++++++++++++++++ src/servala/core/models/service.py | 19 +-- 2 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 src/servala/core/migrations/0008_created_and_updated.py diff --git a/src/servala/core/migrations/0008_created_and_updated.py b/src/servala/core/migrations/0008_created_and_updated.py new file mode 100644 index 0000000..9c20f81 --- /dev/null +++ b/src/servala/core/migrations/0008_created_and_updated.py @@ -0,0 +1,134 @@ +# Generated by Django 5.2b1 on 2025-03-26 14:54 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0007_service_definition"), + ] + + operations = [ + migrations.AddField( + model_name="cloudprovider", + 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="cloudprovider", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="controlplane", + 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="controlplane", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="plan", + 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="plan", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="service", + 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="service", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="servicecategory", + 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="servicecategory", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="servicedefinition", + 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="servicedefinition", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="serviceoffering", + 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="serviceoffering", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + migrations.AddField( + model_name="serviceofferingcontrolplane", + 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="serviceofferingcontrolplane", + name="updated_at", + field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f44e9e6..9bd97b9 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -8,8 +8,10 @@ from encrypted_fields.fields import EncryptedJSONField from kubernetes import config from kubernetes.client.rest import ApiException +from servala.core.models.mixins import ServalaModelMixin -class ServiceCategory(models.Model): + +class ServiceCategory(ServalaModelMixin, models.Model): """ Categories for services, e.g. "Databases", "Storage", "Compute". """ @@ -40,7 +42,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,10 +91,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 @@ -180,7 +183,7 @@ class ControlPlane(models.Model): return False, _("Connection error: {}").format(str(e)) -class CloudProvider(models.Model): +class CloudProvider(ServalaModelMixin, models.Model): """ A cloud provider, e.g. "Exoscale". """ @@ -205,7 +208,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. """ @@ -239,7 +242,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. @@ -282,7 +285,7 @@ class ServiceDefinition(models.Model): return self.name -class ServiceOfferingControlPlane(models.Model): +class ServiceOfferingControlPlane(ServalaModelMixin, models.Model): """ Each combination of ServiceOffering and ControlPlane can have a different ServiceDefinition, which is here modeled as the "through" model. @@ -368,7 +371,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". """ -- 2.47.2 From 26a3a4942fedfb70ee596df0b24189bd45fc8c38 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 11:26:42 +0100 Subject: [PATCH 03/37] First draft of ServiceInstance model --- src/servala/core/models/service.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 9bd97b9..8bde496 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -404,3 +404,35 @@ class ServiceOffering(ServalaModelMixin, models.Model): return _("{service_name} at {provider_name}").format( service_name=self.service.name, provider_name=self.provider.name ) + + +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=100, verbose_name=_("Name")) + 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="created_service_instances", + ) + context = models.ForeignKey( + to="core.ServiceOfferingControlPlane", + related_name="service_instances", + on_delete=models.PROTECT, + ) + + class Meta: + verbose_name = _("Service instance") + verbose_name_plural = _("Service instances") -- 2.47.2 From 9a403d74f2fcac192e076c501f8cbefd1a9daf94 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 12:15:49 +0100 Subject: [PATCH 04/37] Add fields related to instance deletion --- src/servala/core/models/service.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 8bde496..22fc83e 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -425,7 +425,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): on_delete=models.SET_NULL, null=True, blank=True, - related_name="created_service_instances", + related_name="+", ) context = models.ForeignKey( to="core.ServiceOfferingControlPlane", @@ -433,6 +433,16 @@ class ServiceInstance(ServalaModelMixin, models.Model): 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") -- 2.47.2 From a72358a854b783e2bebc8f9b27d6500647387d2f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 13:33:56 +0100 Subject: [PATCH 05/37] Add organization namespaces --- .../migrations/0009_organization_namespace.py | 34 +++++++++++++++++++ src/servala/core/models/organization.py | 19 +++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/servala/core/migrations/0009_organization_namespace.py diff --git a/src/servala/core/migrations/0009_organization_namespace.py b/src/servala/core/migrations/0009_organization_namespace.py new file mode 100644 index 0000000..393db68 --- /dev/null +++ b/src/servala/core/migrations/0009_organization_namespace.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2b1 on 2025-03-28 09:39 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0008_created_and_updated"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="namespace", + field=models.CharField( + default="namespace", + 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_namespace", + message='Namespace 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", + ), + preserve_default=False, + ), + ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index e842c36..cc5eae7 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,6 +1,7 @@ import rules import urlman from django.conf import settings +from django.core.validators import RegexValidator from django.db import models from django.utils.functional import cached_property from django.utils.text import slugify @@ -13,6 +14,24 @@ from servala.core.models.mixins import ServalaModelMixin 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=[ + RegexValidator( + regex=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + message=_( + 'Namespace must consist of lowercase alphanumeric characters or "-", ' + "must start and end with an alphanumeric character." + ), + code="invalid_namespace", + ) + ], + ) billing_entity = models.ForeignKey( to="BillingEntity", -- 2.47.2 From 2c6564614650325a6c3428ede0344144d99cfe7d Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 13:54:53 +0100 Subject: [PATCH 06/37] Fix form error rendering --- src/servala/frontend/forms/renderers.py | 11 ++++++----- .../templates/frontend/forms/field.html | 2 +- .../templates/frontend/forms/form.html | 17 +++++++++++------ .../frontend/templates/includes/form.html | 1 - .../templates/includes/form_errors.html | 18 ------------------ 5 files changed, 18 insertions(+), 31 deletions(-) delete mode 100644 src/servala/frontend/templates/includes/form_errors.html diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index 24e412b..b0d867c 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -17,10 +17,12 @@ 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): @@ -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) diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html index fe56619..744732e 100644 --- a/src/servala/frontend/templates/frontend/forms/field.html +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -1,5 +1,5 @@ {% load i18n %} -
+
{% if not hide_label %} {% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %} diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index a9e8c47..b45c41f 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,12 +1,17 @@ -{% if errors %} +{% load i18n %} +{% if form.non_field_errors or form.errors %} diff --git a/src/servala/frontend/templates/includes/form.html b/src/servala/frontend/templates/includes/form.html index d5a5e8a..bc052fe 100644 --- a/src/servala/frontend/templates/includes/form.html +++ b/src/servala/frontend/templates/includes/form.html @@ -2,7 +2,6 @@
- {% include "includes/form_errors.html" %} {% csrf_token %} {{ form }} {% if extra_field %}{{ extra_field }}{% endif %} diff --git a/src/servala/frontend/templates/includes/form_errors.html b/src/servala/frontend/templates/includes/form_errors.html deleted file mode 100644 index 2be9051..0000000 --- a/src/servala/frontend/templates/includes/form_errors.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load i18n %} -{% if form.non_field_errors or form.errors %} - -{% endif %} -- 2.47.2 From b8e987577256c6acd50a19435d59a704c91f6527 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 15:15:48 +0100 Subject: [PATCH 07/37] Add write-once namespace to admin --- src/servala/core/admin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index b8171f9..66a4b30 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -56,12 +56,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): -- 2.47.2 From 0d4706014166ae45aa1f4ac9c33d8e6265a0a106 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 15:16:53 +0100 Subject: [PATCH 08/37] Add namespace to organization create --- src/servala/frontend/forms/organization.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..d582ce9 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -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." + ) + } -- 2.47.2 From e37e126d9daaf07a7ea2ca64eab43f99b0c1312c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 16:51:36 +0100 Subject: [PATCH 09/37] Show the organization namespace in the detail page --- .../templates/frontend/organizations/update.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 01c919a..993b76a 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -46,6 +46,15 @@ {% partial org-name %} + + + {% translate "Namespace" %} + + +
{{ form.instance.namespace }}
+ {% translate "The namespace cannot be changed after creation." %} + +
-- 2.47.2 From 555462a99ed0739cb7ef33cb64d9ef1819d236c2 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 27 Mar 2025 17:44:25 +0100 Subject: [PATCH 10/37] Extract and reuse kubernetes name validator --- src/servala/core/models/organization.py | 13 ++----------- src/servala/core/models/service.py | 8 +++++++- src/servala/core/validators.py | 12 ++++++++++++ 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 src/servala/core/validators.py diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index cc5eae7..a3c9a15 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -1,7 +1,6 @@ import rules import urlman from django.conf import settings -from django.core.validators import RegexValidator from django.db import models from django.utils.functional import cached_property from django.utils.text import slugify @@ -10,6 +9,7 @@ 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): @@ -21,16 +21,7 @@ class Organization(ServalaModelMixin, models.Model): help_text=_( "This namespace will be used for all Kubernetes resources. Cannot be changed after creation." ), - validators=[ - RegexValidator( - regex=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", - message=_( - 'Namespace must consist of lowercase alphanumeric characters or "-", ' - "must start and end with an alphanumeric character." - ), - code="invalid_namespace", - ) - ], + validators=[kubernetes_name_validator], ) billing_entity = models.ForeignKey( diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 22fc83e..04a6f8f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -9,6 +9,7 @@ from kubernetes import config from kubernetes.client.rest import ApiException from servala.core.models.mixins import ServalaModelMixin +from servala.core.validators import kubernetes_name_validator class ServiceCategory(ServalaModelMixin, models.Model): @@ -16,7 +17,9 @@ class ServiceCategory(ServalaModelMixin, models.Model): Categories for services, e.g. "Databases", "Storage", "Compute". """ - name = models.CharField(max_length=100, verbose_name=_("Name")) + name = models.CharField( + max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator] + ) description = models.TextField(blank=True, verbose_name=_("Description")) logo = models.ImageField( upload_to="public/service_categories", @@ -446,3 +449,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): 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")] diff --git a/src/servala/core/validators.py b/src/servala/core/validators.py new file mode 100644 index 0000000..1723bb5 --- /dev/null +++ b/src/servala/core/validators.py @@ -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", +) -- 2.47.2 From b6260b4e9e1741d2dee88c58c2f5a5ada33b6da5 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 11:29:43 +0100 Subject: [PATCH 11/37] 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", -- 2.47.2 From 57945c8e515c5c51de665c6e999c4f4e6479971a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 11:47:20 +0100 Subject: [PATCH 12/37] 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") -- 2.47.2 From 8a1f72b317372bda94878d3dda657a9aa8cb4327 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:23:03 +0100 Subject: [PATCH 13/37] 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 -- 2.47.2 From 172bdd7261ec4422d1ab85bbeec6902edff75253 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:43:21 +0100 Subject: [PATCH 14/37] 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 -- 2.47.2 From 33b82af67dd6cd42094d67f6b90e07315b06a7c1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:52:03 +0100 Subject: [PATCH 15/37] 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, -- 2.47.2 From 2f607f8271bb5b8303a7ed9b1e414b28287521ae Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:53:10 +0100 Subject: [PATCH 16/37] 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"}) -- 2.47.2 From 174837a87053167ad10e5b300e409a885462dca3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 12:57:02 +0100 Subject: [PATCH 17/37] 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): -- 2.47.2 From 4e44b283b178ff6638395e010cd1a000acc1158a Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:10:23 +0100 Subject: [PATCH 18/37] 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}/" + -- 2.47.2 From 6e644dfe4414b4faec02e332b847122f9e4225d0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:11:17 +0100 Subject: [PATCH 19/37] 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) -- 2.47.2 From 2f769e0e2e3aa93c05d2806ef09b9978fe7ea293 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:46:55 +0100 Subject: [PATCH 20/37] Remove unneeded fields from spec --- src/servala/core/crd.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index a164b1a..8fd20cd 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -28,12 +28,15 @@ 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"} 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")) meta_class = type("Meta", (), {"app_label": "crd_models"}) -- 2.47.2 From 94713a3100a34cc0399dabfd8a4e6051d43ade83 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 28 Mar 2025 13:50:11 +0100 Subject: [PATCH 21/37] wip --- src/servala/core/models/service.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ef6f9c9..096b0de 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -131,6 +131,7 @@ class ControlPlane(ServalaModelMixin, models.Model): "clusters": [ { "cluster": { + "insecure-skip-tls-verify": True, "certificate-authority-data": self.api_credentials[ "certificate-authority-data" ], @@ -460,6 +461,26 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod def create_instance(cls, organization, context, created_by, spec_data): name = spec_data.get("spec.name") + + # Ensure the namespace exists + namespace_name = organization.namespace + api_instance = kubernetes.client.CoreV1Api( + context.control_plane.get_kubernetes_client() + ) + + try: + api_instance.read_namespace(name=namespace_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=namespace_name) + ) + api_instance.create_namespace(body=body) + else: + # If there's another error, raise it + raise + return cls.objects.create( name=name, organization=organization, -- 2.47.2 From 67f4b3ba12041a148620a42f240df0796a8d1df3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:24:57 +0200 Subject: [PATCH 22/37] Rename model, reset migrations --- src/servala/core/admin.py | 12 +- src/servala/core/migrations/0001_initial.py | 539 ++++++++++++++---- ...ed_at_billingentity_updated_at_and_more.py | 89 --- .../0003_billing_entity_nullable.py | 25 - .../0004_encrypt_api_credentials.py | 46 -- ...05_remove_controlplane_k8s_api_endpoint.py | 29 - .../core/migrations/0006_service_slug.py | 21 - .../migrations/0007_service_definition.py | 115 ---- .../migrations/0008_created_and_updated.py | 134 ----- .../migrations/0009_organization_namespace.py | 34 -- .../core/migrations/0010_service_instance.py | 117 ---- ...011_alter_servicecategory_name_and_more.py | 34 -- src/servala/core/models/__init__.py | 4 +- src/servala/core/models/service.py | 24 +- src/servala/frontend/views/service.py | 6 +- 15 files changed, 462 insertions(+), 767 deletions(-) delete mode 100644 src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py delete mode 100644 src/servala/core/migrations/0003_billing_entity_nullable.py delete mode 100644 src/servala/core/migrations/0004_encrypt_api_credentials.py delete mode 100644 src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py delete mode 100644 src/servala/core/migrations/0006_service_slug.py delete mode 100644 src/servala/core/migrations/0007_service_definition.py delete mode 100644 src/servala/core/migrations/0008_created_and_updated.py delete mode 100644 src/servala/core/migrations/0009_organization_namespace.py delete mode 100644 src/servala/core/migrations/0010_service_instance.py delete mode 100644 src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 454be56..dad3d6e 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -6,6 +6,7 @@ from servala.core.models import ( BillingEntity, CloudProvider, ControlPlane, + ControlPlaneCRD, Organization, OrganizationMembership, OrganizationOrigin, @@ -15,7 +16,6 @@ from servala.core.models import ( ServiceDefinition, ServiceInstance, ServiceOffering, - ServiceOfferingControlPlane, User, ) @@ -207,14 +207,14 @@ 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") @@ -263,6 +263,6 @@ class ServiceOfferingAdmin(admin.ModelAdmin): search_fields = ("description",) autocomplete_fields = ("service", "provider") inlines = ( - ServiceOfferingControlPlaneInline, + ControlPlaneCRDInline, PlanInline, ) diff --git a/src/servala/core/migrations/0001_initial.py b/src/servala/core/migrations/0001_initial.py index 583f43d..21ffdba 100644 --- a/src/servala/core/migrations/0001_initial.py +++ b/src/servala/core/migrations/0001_initial.py @@ -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), ), ] diff --git a/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py b/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py deleted file mode 100644 index 83d5c00..0000000 --- a/src/servala/core/migrations/0002_billingentity_created_at_billingentity_updated_at_and_more.py +++ /dev/null @@ -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"), - ), - ] diff --git a/src/servala/core/migrations/0003_billing_entity_nullable.py b/src/servala/core/migrations/0003_billing_entity_nullable.py deleted file mode 100644 index bae2ae2..0000000 --- a/src/servala/core/migrations/0003_billing_entity_nullable.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/src/servala/core/migrations/0004_encrypt_api_credentials.py b/src/servala/core/migrations/0004_encrypt_api_credentials.py deleted file mode 100644 index 9ca4acf..0000000 --- a/src/servala/core/migrations/0004_encrypt_api_credentials.py +++ /dev/null @@ -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" - ), - ), - ] diff --git a/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py b/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py deleted file mode 100644 index b704b0d..0000000 --- a/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/src/servala/core/migrations/0006_service_slug.py b/src/servala/core/migrations/0006_service_slug.py deleted file mode 100644 index f15e796..0000000 --- a/src/servala/core/migrations/0006_service_slug.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/servala/core/migrations/0007_service_definition.py b/src/servala/core/migrations/0007_service_definition.py deleted file mode 100644 index be0fac6..0000000 --- a/src/servala/core/migrations/0007_service_definition.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/src/servala/core/migrations/0008_created_and_updated.py b/src/servala/core/migrations/0008_created_and_updated.py deleted file mode 100644 index 9c20f81..0000000 --- a/src/servala/core/migrations/0008_created_and_updated.py +++ /dev/null @@ -1,134 +0,0 @@ -# Generated by Django 5.2b1 on 2025-03-26 14:54 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0007_service_definition"), - ] - - operations = [ - migrations.AddField( - model_name="cloudprovider", - 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="cloudprovider", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="controlplane", - 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="controlplane", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="plan", - 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="plan", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="service", - 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="service", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="servicecategory", - 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="servicecategory", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="servicedefinition", - 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="servicedefinition", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="serviceoffering", - 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="serviceoffering", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - migrations.AddField( - model_name="serviceofferingcontrolplane", - 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="serviceofferingcontrolplane", - name="updated_at", - field=models.DateTimeField(auto_now=True, verbose_name="Last updated"), - ), - ] diff --git a/src/servala/core/migrations/0009_organization_namespace.py b/src/servala/core/migrations/0009_organization_namespace.py deleted file mode 100644 index 393db68..0000000 --- a/src/servala/core/migrations/0009_organization_namespace.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.2b1 on 2025-03-28 09:39 - -import django.core.validators -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0008_created_and_updated"), - ] - - operations = [ - migrations.AddField( - model_name="organization", - name="namespace", - field=models.CharField( - default="namespace", - 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_namespace", - message='Namespace 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", - ), - preserve_default=False, - ), - ] diff --git a/src/servala/core/migrations/0010_service_instance.py b/src/servala/core/migrations/0010_service_instance.py deleted file mode 100644 index 9989650..0000000 --- a/src/servala/core/migrations/0010_service_instance.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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 deleted file mode 100644 index 859e261..0000000 --- a/src/servala/core/migrations/0011_alter_servicecategory_name_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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 722aabd..3fb0663 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -8,13 +8,13 @@ from .organization import ( from .service import ( CloudProvider, ControlPlane, + ControlPlaneCRD, Plan, Service, ServiceCategory, ServiceDefinition, ServiceInstance, ServiceOffering, - ServiceOfferingControlPlane, ) from .user import User @@ -22,6 +22,7 @@ __all__ = [ "BillingEntity", "CloudProvider", "ControlPlane", + "ControlPlaneCRD", "Organization", "OrganizationMembership", "OrganizationOrigin", @@ -32,6 +33,5 @@ __all__ = [ "ServiceInstance", "ServiceDefinition", "ServiceOffering", - "ServiceOfferingControlPlane", "User", ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 096b0de..5c9dd90 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -121,6 +121,12 @@ class ControlPlane(ServalaModelMixin, 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() @@ -288,10 +294,10 @@ class ServiceDefinition(ServalaModelMixin, models.Model): return self.name -class ServiceOfferingControlPlane(ServalaModelMixin, 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( @@ -391,12 +397,6 @@ class ServiceOffering(ServalaModelMixin, 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: @@ -408,6 +408,12 @@ class ServiceOffering(ServalaModelMixin, models.Model): 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): """ @@ -433,7 +439,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="+", ) context = models.ForeignKey( - to="core.ServiceOfferingControlPlane", + to="core.ControlPlaneCRD", related_name="service_instances", on_delete=models.PROTECT, ) diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index e99e5a8..cfb1ff8 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -4,10 +4,10 @@ from django.utils.functional import cached_property from django.views.generic import DetailView, ListView from servala.core.models import ( + ControlPlaneCRD, Service, ServiceInstance, ServiceOffering, - ServiceOfferingControlPlane, ) from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin @@ -89,12 +89,12 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView @cached_property def context_object(self): if self.request.method == "POST": - return ServiceOfferingControlPlane.objects.filter( + return ControlPlaneCRD.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( + return ControlPlaneCRD.objects.filter( control_plane=self.selected_plane, service_offering=self.object ).first() -- 2.47.2 From 9c0bbdcf921cc0939e14e469f320d7823dfeae38 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:25:17 +0200 Subject: [PATCH 23/37] Superusers should also be staff --- src/servala/core/management/commands/make_superuser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/servala/core/management/commands/make_superuser.py b/src/servala/core/management/commands/make_superuser.py index fe43ec8..a060385 100644 --- a/src/servala/core/management/commands/make_superuser.py +++ b/src/servala/core/management/commands/make_superuser.py @@ -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.")) -- 2.47.2 From 8b63d7863304c588b49e60900936b2c35a1cba21 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:32:16 +0200 Subject: [PATCH 24/37] Fix form rendering --- src/servala/core/models/organization.py | 2 +- src/servala/core/models/service.py | 4 ++-- src/servala/frontend/forms/renderers.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index e3c2bf1..62894d5 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -49,7 +49,7 @@ class Organization(ServalaModelMixin, models.Model): base = "/org/{self.slug}/" details = "{base}details/" services = "{base}services/" - services = "{base}instances/" + instances = "{base}instances/" @cached_property def slug(self): diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 5c9dd90..5d34d80 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -320,8 +320,8 @@ class ControlPlaneCRD(ServalaModelMixin, 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): diff --git a/src/servala/frontend/forms/renderers.py b/src/servala/frontend/forms/renderers.py index b0d867c..b6a9995 100644 --- a/src/servala/frontend/forms/renderers.py +++ b/src/servala/frontend/forms/renderers.py @@ -27,7 +27,7 @@ class VerticalFormRenderer(TemplatesSetting): 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 -- 2.47.2 From eedb90b2d3dc4b5a04344d7d04bce390d85f2d89 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:40:13 +0200 Subject: [PATCH 25/37] Mark fields as required only if parent also required --- src/servala/core/crd.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 8fd20cd..e1ca8d2 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -37,7 +37,7 @@ def generate_django_model(schema, group, version, kind): # resourceRef object spec = schema["properties"].get("spec") or {} spec["properties"].pop("resourceRef", None) - model_fields.update(build_object_fields(spec, "spec")) + model_fields.update(build_object_fields(spec, "spec", parent_required=True)) meta_class = type("Meta", (), {"app_label": "crd_models"}) model_fields["Meta"] = meta_class @@ -48,13 +48,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, @@ -85,8 +85,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": [], @@ -118,8 +119,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 @@ -163,6 +168,12 @@ class CrdModelFormMixin: current = current[part] return result + def clean(self): + cleaned_data = super().clean() + # TODO implement custom validation logic + # nested_data = self.get_nested_data() + return cleaned_data + def generate_model_form_class(model): meta_attrs = { -- 2.47.2 From d10fa1c97387b112093117fd6c86b04fad6e177c Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:52:02 +0200 Subject: [PATCH 26/37] New dependency: jsonschema (validation) --- pyproject.toml | 1 + uv.lock | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 71f4522..52b536a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index 2b6a71a..0592903 100644 --- a/uv.lock +++ b/uv.lock @@ -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" -- 2.47.2 From 5933881262e8094420292fa88bf9679340487094 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 11:52:22 +0200 Subject: [PATCH 27/37] Validate forms based on JSON schema --- src/servala/core/crd.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index e1ca8d2..42ca351 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -5,6 +5,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator, RegexVa from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass from django.utils.translation import gettext_lazy as _ +from jsonschema import validate from servala.core.models import ServiceInstance @@ -39,6 +40,9 @@ def generate_django_model(schema, group, version, kind): 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 @@ -138,6 +142,7 @@ def get_django_field( class CrdModelFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.schema = self._meta.model.SCHEMA for field in ("organization", "context"): self.fields[field].widget = forms.HiddenInput() @@ -170,10 +175,18 @@ class CrdModelFormMixin: def clean(self): cleaned_data = super().clean() - # TODO implement custom validation logic - # nested_data = self.get_nested_data() + 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""" + try: + validate(instance=data, schema=schema) + except Exception as e: + raise forms.ValidationError(f"Validation error: {e.message}") + def generate_model_form_class(model): meta_attrs = { -- 2.47.2 From 412d344536233af69c079ad2fec0c1e2eb25c47b Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 13:27:25 +0200 Subject: [PATCH 28/37] Show nested fieldsets for form --- src/servala/core/crd.py | 61 +++++++++++++++++++ .../templates/frontend/forms/field.html | 4 +- .../service_offering_detail.html | 2 +- .../includes/tabbed_fieldset_form.html | 50 +++++++++++++++ src/servala/frontend/templatetags/__init__.py | 0 .../frontend/templatetags/get_field.py | 8 +++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/servala/frontend/templates/includes/tabbed_fieldset_form.html create mode 100644 src/servala/frontend/templatetags/__init__.py create mode 100644 src/servala/frontend/templatetags/get_field.py diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 42ca351..48df661 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -147,6 +147,67 @@ class CrdModelFormMixin: for field in ("organization", "context"): self.fields[field].widget = forms.HiddenInput() + 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": parent_key.title(), + } + 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": sub_key.title(), + "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]) + else: + total_fields += field_count + total_fields += len(group["fields"]) + if total_fields == 1: + others.append(group["fields"][0]) + else: + 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. diff --git a/src/servala/frontend/templates/frontend/forms/field.html b/src/servala/frontend/templates/frontend/forms/field.html index 744732e..3e0a30b 100644 --- a/src/servala/frontend/templates/frontend/forms/field.html +++ b/src/servala/frontend/templates/frontend/forms/field.html @@ -1,6 +1,6 @@ {% load i18n %}
- {% if not hide_label %} + {% if not hide_label and not field.is_hidden %} {% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %} {% endif %} @@ -9,7 +9,7 @@
{% 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 %} {% endif %} {% if field.use_fieldset %}
{% endif %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 066c1af..184ff49 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -17,7 +17,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/form.html" with form=service_form %} + {% include "includes/tabbed_fieldset_form.html" with form=service_form %} {% endif %}
diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html new file mode 100644 index 0000000..6c69bc3 --- /dev/null +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -0,0 +1,50 @@ +{% load i18n %} +{% load get_field %} + + {% csrf_token %} + +
+ {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets.values %} +

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% endfor %} +
+ {% endfor %} +
+
+ +
+ diff --git a/src/servala/frontend/templatetags/__init__.py b/src/servala/frontend/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/frontend/templatetags/get_field.py b/src/servala/frontend/templatetags/get_field.py new file mode 100644 index 0000000..3214beb --- /dev/null +++ b/src/servala/frontend/templatetags/get_field.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + + +@register.filter +def get_field(form, field_name): + return form[field_name] -- 2.47.2 From f22dc98c238ff7863f7b7598561088e0fb6e6ddb Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 13:43:51 +0200 Subject: [PATCH 29/37] Improve form display: do not repeat section title --- pyproject.toml | 1 + src/servala/core/crd.py | 19 +++++++++++++++---- .../includes/tabbed_fieldset_form.html | 10 ++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 52b536a..27d42c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,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" diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 48df661..2793d75 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -147,6 +147,11 @@ class CrdModelFormMixin: 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 = [] @@ -174,7 +179,7 @@ class CrdModelFormMixin: nested_fieldsets[parent_key] = { "fields": [], "fieldsets": {}, - "title": parent_key.title(), + "title": deslugify(parent_key), } parent = nested_fieldsets[parent_key] if len(parts) == 3: # Top-level within fieldset @@ -183,7 +188,7 @@ class CrdModelFormMixin: sub_key = parts[2] if not parent["fieldsets"].get(sub_key): parent["fieldsets"][sub_key] = { - "title": sub_key.title(), + "title": deslugify(sub_key), "fields": [], } parent["fieldsets"][sub_key]["fields"].append(field_name) @@ -194,12 +199,18 @@ class CrdModelFormMixin: 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 - total_fields += len(group["fields"]) - if total_fields == 1: + 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 diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 6c69bc3..387f7a6 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -30,10 +30,12 @@ {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} {% endfor %} {% for subfieldset in fieldset.fieldsets.values %} -

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} + {% if subfieldset.fields %} +

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% endif %} {% endfor %} {% endfor %} -- 2.47.2 From 70b8303fdb746aa60a3c9d4658f3f6e62dd8aed3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 13:52:44 +0200 Subject: [PATCH 30/37] Defer validation --- src/servala/core/crd.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 2793d75..2c2b9fb 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -5,7 +5,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator, RegexVa from django.db import models from django.forms.models import ModelForm, ModelFormMetaclass from django.utils.translation import gettext_lazy as _ -from jsonschema import validate from servala.core.models import ServiceInstance @@ -254,10 +253,14 @@ class CrdModelFormMixin: def validate_nested_data(self, data, schema): """Validate data against the provided OpenAPI v3 schema""" - try: - validate(instance=data, schema=schema) - except Exception as e: - raise forms.ValidationError(f"Validation error: {e.message}") + # 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): -- 2.47.2 From da69666389062198df2e13d16fc2bc832c40871f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Mon, 31 Mar 2025 13:52:57 +0200 Subject: [PATCH 31/37] Refactor namespace handling --- src/servala/core/models/service.py | 37 ++++++++++++--------------- src/servala/frontend/views/service.py | 1 + 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 5d34d80..9e282a6 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -191,6 +191,21 @@ class ControlPlane(ServalaModelMixin, 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(ServalaModelMixin, models.Model): """ @@ -465,27 +480,9 @@ class ServiceInstance(ServalaModelMixin, models.Model): base = "{self.organization.urls.instances}{self.name}/" @classmethod - def create_instance(cls, organization, context, created_by, spec_data): - name = spec_data.get("spec.name") - + def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists - namespace_name = organization.namespace - api_instance = kubernetes.client.CoreV1Api( - context.control_plane.get_kubernetes_client() - ) - - try: - api_instance.read_namespace(name=namespace_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=namespace_name) - ) - api_instance.create_namespace(body=body) - else: - # If there's another error, raise it - raise + context.control_plane.get_or_create_namespace(organization.namespace) return cls.objects.create( name=name, diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index cfb1ff8..1803dd0 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -128,6 +128,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView 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(), -- 2.47.2 From a2c3695611b03648e929baa9d67fce7194de9cef Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 16:20:52 +0200 Subject: [PATCH 32/37] Fix breaking tests --- src/tests/conftest.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index d88870e..a69e50c 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -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 -- 2.47.2 From 08fada04e032e25d6b5597609df3ac0aa53c4ecb Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 17:17:33 +0200 Subject: [PATCH 33/37] Successfully create an instance --- src/servala/core/models/service.py | 30 +++++++++++++++++++++++++-- src/servala/frontend/views/service.py | 2 +- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 9e282a6..8156adf 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -6,7 +6,7 @@ from django.db import 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 @@ -137,7 +137,6 @@ class ControlPlane(ServalaModelMixin, models.Model): "clusters": [ { "cluster": { - "insecure-skip-tls-verify": True, "certificate-authority-data": self.api_credentials[ "certificate-authority-data" ], @@ -484,6 +483,33 @@ class ServiceInstance(ServalaModelMixin, models.Model): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization.namespace) + 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 {}, + } + 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, + ) + return cls.objects.create( name=name, organization=organization, diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 1803dd0..c19aff5 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -131,7 +131,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView name=form.cleaned_data["name"], context=self.context_object, created_by=request.user, - spec_data=form.get_nested_data(), + spec_data=form.get_nested_data().get("spec"), ) return redirect(service_instance.urls.base) except Exception as e: -- 2.47.2 From bc8c7a80b2cf499154827798a00d10ccf9288a78 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 17:39:49 +0200 Subject: [PATCH 34/37] Add required_label field to ControlPlane --- src/servala/core/admin.py | 2 +- ..._alter_controlplanecrd_options_and_more.py | 31 +++++++++++++++++++ src/servala/core/models/service.py | 12 +++++++ src/servala/settings.py | 1 + 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index dad3d6e..7d110d1 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -131,7 +131,7 @@ class ControlPlaneAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("name", "description", "cloud_provider")}, + {"fields": ("name", "description", "cloud_provider", "required_label")}, ), ( _("API Credentials"), diff --git a/src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py b/src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py new file mode 100644 index 0000000..63cab45 --- /dev/null +++ b/src/servala/core/migrations/0002_alter_controlplanecrd_options_and_more.py @@ -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", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 8156adf..f9afedc 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,5 +1,6 @@ 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 @@ -106,6 +107,15 @@ class ControlPlane(ServalaModelMixin, 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", @@ -495,6 +505,8 @@ class ServiceInstance(ServalaModelMixin, models.Model): }, "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() ) diff --git a/src/servala/settings.py b/src/servala/settings.py index 3e27e35..50b0c5e 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -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 -- 2.47.2 From a2ac202f265e8af45c5c7b6303ee652b7836ec7f Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 17:41:07 +0200 Subject: [PATCH 35/37] Fix label metadata --- src/servala/core/models/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f9afedc..f60d57c 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -506,7 +506,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): "spec": spec_data or {}, } if label := context.control_plane.required_label: - create_data["metadata"]["labels"] = [{settings.DEFAULT_LABEL_KEY: label}] + create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label} api_instance = client.CustomObjectsApi( context.control_plane.get_kubernetes_client() ) -- 2.47.2 From fb5a6e9a4265e26afde03fa0d75b642ce8123ef9 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 17:54:31 +0200 Subject: [PATCH 36/37] Improve name uniqueness feedback --- src/servala/core/models/service.py | 23 +++++++++++++++-------- src/servala/frontend/views/service.py | 3 ++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index f60d57c..4cd3096 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -3,7 +3,7 @@ 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 @@ -492,6 +492,19 @@ class ServiceInstance(ServalaModelMixin, models.Model): 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." + ) + ) group = context.service_definition.api_definition["group"] version = context.service_definition.api_definition["version"] @@ -521,10 +534,4 @@ class ServiceInstance(ServalaModelMixin, models.Model): plural=plural, body=create_data, ) - - return cls.objects.create( - name=name, - organization=organization, - created_by=created_by, - context=context, - ) + return instance diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index c19aff5..8816721 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -135,7 +135,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) return redirect(service_instance.urls.base) except Exception as e: - messages.error(self.request, str(e)) + error_message = getattr(e, "message", None) or str(e) + messages.error(self.request, error_message) # If the form is not valid or if the service creation failed, we render it again context["service_form"] = form -- 2.47.2 From 4d13d71953f4c7a2f144094596c8dec1b8486601 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Thu, 3 Apr 2025 18:01:15 +0200 Subject: [PATCH 37/37] Improve display of kubernetes errors --- src/servala/core/models/service.py | 69 ++++++++++++++++----------- src/servala/frontend/views/service.py | 6 ++- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 4cd3096..41cc896 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,3 +1,5 @@ +import json + import kubernetes import urlman from django.conf import settings @@ -506,32 +508,45 @@ class ServiceInstance(ServalaModelMixin, models.Model): ) ) - 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" + 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, - ) + 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 diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 8816721..edf889a 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -1,4 +1,5 @@ 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 @@ -134,9 +135,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView 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: - error_message = getattr(e, "message", None) or str(e) - messages.error(self.request, error_message) + 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 -- 2.47.2