Compare commits
9 commits
555462a99e
...
6e644dfe44
Author | SHA1 | Date | |
---|---|---|---|
6e644dfe44 | |||
4e44b283b1 | |||
174837a870 | |||
2f607f8271 | |||
33b82af67d | |||
172bdd7261 | |||
8a1f72b317 | |||
57945c8e51 | |||
b6260b4e9e |
9 changed files with 325 additions and 24 deletions
|
@ -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")
|
||||
|
|
|
@ -1,10 +1,28 @@
|
|||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.forms.models import ModelForm, ModelFormMetaclass
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import ServiceInstance
|
||||
|
||||
|
||||
def duplicate_field(field_name, model):
|
||||
# Get the field from the model
|
||||
field = model._meta.get_field(field_name)
|
||||
|
||||
# Create a new field with the same attributes
|
||||
new_field = type(field).__new__(type(field))
|
||||
new_field.__dict__.update(field.__dict__)
|
||||
|
||||
# Ensure the field is not linked to the original model
|
||||
new_field.model = None
|
||||
new_field.auto_created = False
|
||||
|
||||
return new_field
|
||||
|
||||
|
||||
def generate_django_model(schema, group, version, kind):
|
||||
"""
|
||||
|
@ -14,6 +32,8 @@ def generate_django_model(schema, group, version, kind):
|
|||
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
|
||||
|
||||
model_fields = {"__module__": "crd_models"}
|
||||
for field_name in ("name", "organization", "context"):
|
||||
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
|
||||
model_fields.update(build_object_fields(spec, "spec"))
|
||||
|
||||
meta_class = type("Meta", (), {"app_label": "crd_models"})
|
||||
|
@ -110,8 +130,35 @@ def get_django_field(
|
|||
class CrdModelFormMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# self.fields["apiVersion"].disabled = True
|
||||
# self.fields["kind"].disabled = True
|
||||
|
||||
for field in ("organization", "context"):
|
||||
self.fields[field].widget = forms.HiddenInput()
|
||||
|
||||
def get_nested_data(self):
|
||||
"""
|
||||
Builds the original nested JSON structure from flat form data.
|
||||
Form fields are named with dot notation (e.g., 'spec.replicas')
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value is None or value == "":
|
||||
continue
|
||||
|
||||
parts = field_name.split(".")
|
||||
current = result
|
||||
|
||||
# Navigate through the nested structure
|
||||
for i, part in enumerate(parts):
|
||||
if i == len(parts) - 1:
|
||||
# Last part, set the value
|
||||
current[part] = value
|
||||
else:
|
||||
# Create nested dict if it doesn't exist
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
return result
|
||||
|
||||
|
||||
def generate_model_form_class(model):
|
||||
|
|
117
src/servala/core/migrations/0010_service_instance.py
Normal file
117
src/servala/core/migrations/0010_service_instance.py
Normal file
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import kubernetes
|
||||
import urlman
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
@ -17,9 +18,7 @@ class ServiceCategory(ServalaModelMixin, models.Model):
|
|||
Categories for services, e.g. "Databases", "Storage", "Compute".
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
logo = models.ImageField(
|
||||
upload_to="public/service_categories",
|
||||
|
@ -416,7 +415,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
on the fly.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
name = models.CharField(
|
||||
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
to="core.Organization",
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -452,3 +453,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
# Names are unique per de-facto namespace, which is defined by the
|
||||
# Organization + ServiceDefinition (group, version) + the ControlPlane.
|
||||
unique_together = [("name", "organization", "context")]
|
||||
|
||||
class urls(urlman.Urls):
|
||||
base = "{self.organization.urls.instances}{self.name}/"
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, organization, context, created_by, spec_data):
|
||||
name = spec_data.get("spec.name")
|
||||
return cls.objects.create(
|
||||
name=name,
|
||||
organization=organization,
|
||||
created_by=created_by,
|
||||
context=context,
|
||||
)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane
|
||||
from servala.core.models import (
|
||||
Service,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
ServiceOfferingControlPlane,
|
||||
)
|
||||
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
|
||||
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||
|
||||
|
@ -68,26 +75,67 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
data = None
|
||||
if "control_plane" in self.request.GET:
|
||||
data = self.request.GET
|
||||
elif self.request.method == "POST" and self.context_object:
|
||||
data = {"control_plane": self.context_object.control_plane_id}
|
||||
return ControlPlaneSelectForm(data=data, planes=self.planes)
|
||||
|
||||
@cached_property
|
||||
def selected_plane(self):
|
||||
if self.select_form.data and self.select_form.is_valid():
|
||||
return self.select_form.cleaned_data["control_plane"]
|
||||
field = self.select_form.fields["control_plane"]
|
||||
return field.initial or field.queryset.first()
|
||||
|
||||
@cached_property
|
||||
def context_object(self):
|
||||
if self.request.method == "POST":
|
||||
return ServiceOfferingControlPlane.objects.filter(
|
||||
pk=self.request.POST.get("context"),
|
||||
# Make sure we don’t use a malicious ID
|
||||
control_plane__in=self.planes,
|
||||
).first()
|
||||
return ServiceOfferingControlPlane.objects.filter(
|
||||
control_plane=self.selected_plane, service_offering=self.object
|
||||
).first()
|
||||
|
||||
def get_instance_form(self):
|
||||
return self.context_object.model_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial={
|
||||
"organization": self.request.organization,
|
||||
"context": self.context_object,
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["select_form"] = self.select_form
|
||||
context["has_control_planes"] = self.planes.exists()
|
||||
if "control_plane" in self.request.GET:
|
||||
if self.select_form.is_valid():
|
||||
context["selected_plane"] = self.select_form.cleaned_data[
|
||||
"control_plane"
|
||||
]
|
||||
try:
|
||||
so_cp = ServiceOfferingControlPlane.objects.filter(
|
||||
control_plane=self.select_form.cleaned_data["control_plane"],
|
||||
service_offering=self.object,
|
||||
).first()
|
||||
if not so_cp:
|
||||
context["form_error"] = True
|
||||
except Exception:
|
||||
context["form_error"] = True
|
||||
else:
|
||||
context["service_form"] = so_cp.model_form_class()
|
||||
context["selected_plane"] = self.selected_plane
|
||||
context["service_form"] = self.get_instance_form()
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
context = self.get_context_data(object=self.object)
|
||||
|
||||
if not self.context_object:
|
||||
context["form_error"] = True
|
||||
return self.render_to_response(context)
|
||||
|
||||
form = self.get_instance_form()
|
||||
if form.is_valid():
|
||||
try:
|
||||
service_instance = ServiceInstance.create_instance(
|
||||
organization=self.organization,
|
||||
context=self.context_object,
|
||||
created_by=request.user,
|
||||
spec_data=form.get_nested_data(),
|
||||
)
|
||||
return redirect(service_instance.urls.base)
|
||||
except Exception as e:
|
||||
messages.error(self.request, str(e))
|
||||
|
||||
# If the form is not valid or if the service creation failed, we render it again
|
||||
context["service_form"] = form
|
||||
return self.render_to_response(context)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue