diff --git a/pyproject.toml b/pyproject.toml index 71f4522..27d42c7 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", @@ -42,6 +43,7 @@ known_first_party = "servala" [tool.flake8] max-line-length = 160 exclude = ".venv" +ignore = "E203" [tool.djlint] extend_exclude = "src/servala/static/mazer" 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/crd.py b/src/servala/core/crd.py index a164b1a..2793d75 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 @@ -28,13 +29,19 @@ 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) - model_fields.update(build_object_fields(spec, "spec")) + + # All other fields are generated from the schema, except for the + # resourceRef object + spec = schema["properties"].get("spec") or {} + spec["properties"].pop("resourceRef", None) + model_fields.update(build_object_fields(spec, "spec", parent_required=True)) + + # Store the original schema on the model class + model_fields["SCHEMA"] = schema meta_class = type("Meta", (), {"app_label": "crd_models"}) model_fields["Meta"] = meta_class @@ -45,13 +52,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, @@ -82,8 +89,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": [], @@ -115,8 +123,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 @@ -130,10 +142,83 @@ 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() + def strip_title(self, field_name, label): + field = self.fields[field_name] + if field and field.label.startswith(label): + field.label = field.label[len(label) :] + + def get_fieldsets(self): + fieldsets = [] + + # General fieldset for non-spec fields + general_fields = [ + field for field in self.fields if not field.startswith("spec.") + ] + if general_fields: + fieldsets.append( + {"title": "General", "fields": general_fields, "fieldsets": []} + ) + + # Process spec fields + others = [] + nested_fieldsets = {} + + for field_name in self.fields: + if field_name.startswith("spec."): + parts = field_name.split(".") + if len(parts) == 2: # Top-level spec field + others.append(field_name) + else: + parent_key = parts[1] + if not nested_fieldsets.get(parent_key): + nested_fieldsets[parent_key] = { + "fields": [], + "fieldsets": {}, + "title": deslugify(parent_key), + } + parent = nested_fieldsets[parent_key] + if len(parts) == 3: # Top-level within fieldset + parent["fields"].append(field_name) + else: + sub_key = parts[2] + if not parent["fieldsets"].get(sub_key): + parent["fieldsets"][sub_key] = { + "title": deslugify(sub_key), + "fields": [], + } + parent["fieldsets"][sub_key]["fields"].append(field_name) + + # Add nested fieldsets to fieldsets + for group in nested_fieldsets.values(): + total_fields = 0 + for fieldset in group["fieldsets"].values(): + if (field_count := len(fieldset["fields"])) == 1: + group["fields"].append(fieldset["fields"][0]) + fieldset["fields"] = [] + else: + title = f"{group['title']}: {fieldset['title']}: " + for field in fieldset["fields"]: + self.strip_title(field, title) + total_fields += field_count + if (total_fields + len(group["fields"])) == 1: + others.append(group["fields"][0]) + else: + title = f"{group['title']}: " + for field in group["fields"]: + self.strip_title(field, title) + fieldsets.append(group) + + # Add 'others' tab if there are any fields + if others: + fieldsets.append({"title": "Others", "fields": others, "fieldsets": []}) + + return fieldsets + def get_nested_data(self): """ Builds the original nested JSON structure from flat form data. @@ -160,6 +245,20 @@ class CrdModelFormMixin: current = current[part] return result + def clean(self): + cleaned_data = super().clean() + self.validate_nested_data( + self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"] + ) + return cleaned_data + + def validate_nested_data(self, data, schema): + """Validate data against the provided OpenAPI v3 schema""" + 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 = { 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.")) 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/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 ef6f9c9..5d34d80 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() @@ -131,6 +137,7 @@ class ControlPlane(ServalaModelMixin, models.Model): "clusters": [ { "cluster": { + "insecure-skip-tls-verify": True, "certificate-authority-data": self.api_credentials[ "certificate-authority-data" ], @@ -287,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( @@ -313,8 +320,8 @@ class ServiceOfferingControlPlane(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): @@ -390,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: @@ -407,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): """ @@ -432,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, ) @@ -460,6 +467,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, 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 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 %}