diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py index 2c2b9fb..f356c7b 100644 --- a/src/servala/core/crd.py +++ b/src/servala/core/crd.py @@ -9,6 +9,18 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ServiceInstance +class CRDModel(models.Model): + """Base class for all virtual CRD models""" + + def __init__(self, **kwargs): + if spec := kwargs.pop("spec", None): + kwargs.update(unnest_data({"spec": spec})) + super().__init__(**kwargs) + + class Meta: + abstract = True + + def duplicate_field(field_name, model): # Get the field from the model field = model._meta.get_field(field_name) @@ -47,7 +59,7 @@ def generate_django_model(schema, group, version, kind): # create the model class model_name = kind - model_class = type(model_name, (models.Model,), model_fields) + model_class = type(model_name, (CRDModel,), model_fields) return model_class @@ -138,6 +150,21 @@ def get_django_field( return models.CharField(max_length=255, **kwargs) +def unnest_data(data): + result = {} + + def _flatten_dict(d, parent_key=""): + for key, value in d.items(): + new_key = f"{parent_key}.{key}" if parent_key else key + if isinstance(value, dict): + _flatten_dict(value, new_key) + else: + result[new_key] = value + + _flatten_dict(data) + return result + + class CrdModelFormMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -146,6 +173,11 @@ class CrdModelFormMixin: for field in ("organization", "context"): self.fields[field].widget = forms.HiddenInput() + if self.instance and self.instance.pk: + self.fields["name"].disabled = True + self.fields["name"].help_text = _("Name cannot be changed after creation.") + self.fields["name"].widget = forms.HiddenInput() + def strip_title(self, field_name, label): field = self.fields[field_name] if field and field.label.startswith(label): @@ -156,7 +188,10 @@ class CrdModelFormMixin: # General fieldset for non-spec fields general_fields = [ - field for field in self.fields if not field.startswith("spec.") + field_name + for field_name, field in self.fields.items() + if not field_name.startswith("spec.") + and not isinstance(field.widget, forms.HiddenInput) ] if general_fields: fieldsets.append( diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index c38cac0..901a822 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -188,6 +188,10 @@ class ControlPlane(ServalaModelMixin, models.Model): def get_kubernetes_client(self): return kubernetes.client.ApiClient(self.kubernetes_config) + @cached_property + def custom_objects_api(self): + return client.CustomObjectsApi(self.get_kubernetes_client()) + def test_connection(self): if not self.api_credentials: return False, _("No API credentials provided") @@ -509,6 +513,19 @@ class ServiceInstance(ServalaModelMixin, models.Model): class urls(urlman.Urls): base = "{self.organization.urls.instances}{self.name}/" + update = "{base}update/" + + def _clear_kubernetes_caches(self): + """Clears cached properties that depend on Kubernetes state.""" + attrs = self.__dict__.keys() + for key in ( + "kubernetes_object", + "spec", + "status_conditions", + "connection_credentials", + ): + if key in attrs: + delattr(self, key) @classmethod def create_instance(cls, name, organization, context, created_by, spec_data): @@ -540,9 +557,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): } 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() - ) + api_instance = context.control_plane.custom_objects_api api_instance.create_namespaced_custom_object( group=context.group, version=context.version, @@ -562,6 +577,47 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(_("Error creating instance: {}").format(str(e))) return instance + def update_spec(self, spec_data, updated_by): + try: + api_instance = self.context.control_plane.custom_objects_api + patch_body = {"spec": spec_data} + + api_instance.patch_namespaced_custom_object( + group=self.context.group, + version=self.context.version, + namespace=self.organization.namespace, + plural=self.context.kind_plural, + name=self.name, + body=patch_body, + ) + self._clear_kubernetes_caches() + self.save() # Updates updated_at timestamp + except ApiException as e: + if e.status == 404: + raise ValidationError( + _( + "Service instance not found in Kubernetes. It may have been deleted externally." + ) + ) + try: + error_body = json.loads(e.body) + reason = error_body.get("message", str(e)) + raise ValidationError( + _("Kubernetes API error updating instance: {error}").format( + error=reason + ) + ) + except (ValueError, TypeError): + raise ValidationError( + _("Kubernetes API error updating instance: {error}").format( + error=str(e) + ) + ) + except Exception as e: + raise ValidationError( + _("Error updating instance: {error}").format(error=str(e)) + ) + @cached_property def kubernetes_object(self): """Fetch the Kubernetes custom resource object""" @@ -595,6 +651,20 @@ class ServiceInstance(ServalaModelMixin, models.Model): spec.pop("writeConnectionSecretToRef", None) return spec + @cached_property + def spec_object(self): + """Dynamically generated CRD object.""" + return self.context.django_model( + name=self.name, + organization=self.organization, + context=self.context, + spec=self.spec, + # We pass -1 as ID in order to make it clear that a) this object exists (remotely), + # and b) it’s not a normal database object. This allows us to treat e.g. update + # forms differently from create forms. + pk=-1, + ) + @cached_property def status_conditions(self): if not self.kubernetes_object: diff --git a/src/servala/frontend/templates/frontend/base.html b/src/servala/frontend/templates/frontend/base.html index f5ce6dd..89d363f 100644 --- a/src/servala/frontend/templates/frontend/base.html +++ b/src/servala/frontend/templates/frontend/base.html @@ -25,9 +25,13 @@

- {% block page_title %} - Dashboard - {% endblock page_title %} + + {% block page_title %} + Dashboard + {% endblock page_title %} + + {% block page_title_extra %} + {% endblock page_title_extra %}

diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index b9cf4dc..973d2f1 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -5,6 +5,11 @@ {{ instance.name }} {% endblock page_title %} {% endblock html_title %} +{% block page_title_extra %} + {% if has_change_permission %} + {% translate "Edit" %} + {% endif %} +{% endblock page_title_extra %} {% block content %}
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html new file mode 100644 index 0000000..51a9213 --- /dev/null +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -0,0 +1,43 @@ +{% extends "frontend/base.html" %} +{% load i18n %} +{% load static %} +{% load partials %} +{% block html_title %} + {% block page_title %} + {% block title %} + {% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %} + {% endblock %} + {% endblock page_title %} +{% endblock html_title %} +{% block page_title_extra %} + {% translate "Back" %} +{% endblock page_title_extra %} +{% partialdef service-form %} +{% if form %} +
+
+
+ {% if form_error %} +
+ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %} +
+ {% else %} + {% include "includes/tabbed_fieldset_form.html" with form=form %} + {% endif %} +
+
+{% endif %} +{% endpartialdef %} +{% block content %} +
+
+ {% if not form %} + + {% else %} +
{% partial service-form %}
+ {% endif %} +
+
+{% endblock content %} diff --git a/src/servala/frontend/urls.py b/src/servala/frontend/urls.py index 2838dae..64c84bf 100644 --- a/src/servala/frontend/urls.py +++ b/src/servala/frontend/urls.py @@ -50,6 +50,11 @@ urlpatterns = [ views.ServiceInstanceDetailView.as_view(), name="organization.instance", ), + path( + "instances//update/", + views.ServiceInstanceUpdateView.as_view(), + name="organization.instance.update", + ), ] ), ), diff --git a/src/servala/frontend/views/__init__.py b/src/servala/frontend/views/__init__.py index 5de38ee..d13d4d5 100644 --- a/src/servala/frontend/views/__init__.py +++ b/src/servala/frontend/views/__init__.py @@ -9,6 +9,7 @@ from .service import ( ServiceDetailView, ServiceInstanceDetailView, ServiceInstanceListView, + ServiceInstanceUpdateView, ServiceListView, ServiceOfferingDetailView, ) @@ -22,6 +23,7 @@ __all__ = [ "ServiceDetailView", "ServiceInstanceDetailView", "ServiceInstanceListView", + "ServiceInstanceUpdateView", "ServiceListView", "ServiceOfferingDetailView", "ProfileView", diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index b6ecc42..298d5bc 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -2,6 +2,7 @@ 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.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView from servala.core.crd import deslugify @@ -16,7 +17,11 @@ from servala.frontend.forms.service import ( ServiceFilterForm, ServiceInstanceFilterForm, ) -from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin +from servala.frontend.views.mixins import ( + HtmxUpdateView, + HtmxViewMixin, + OrganizationViewMixin, +) class ServiceListView(OrganizationViewMixin, ListView): @@ -105,6 +110,8 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ).first() def get_instance_form(self): + if not self.context_object: + return None return self.context_object.model_form_class( data=self.request.POST if self.request.method == "POST" else None, initial={ @@ -130,6 +137,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView return self.render_to_response(context) form = self.get_instance_form() + if not form: # Should not happen if context_object is valid, but as a safeguard + messages.error(self.request, _("Could not initialize service form.")) + return self.render_to_response(context) + if form.is_valid(): try: service_instance = ServiceInstance.create_instance( @@ -143,22 +154,24 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView except ValidationError as e: messages.error(self.request, e.message or str(e)) except Exception as e: - messages.error(self.request, str(e)) + messages.error( + self.request, _("Error creating instance: {}").format(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) -class ServiceInstanceDetailView(OrganizationViewMixin, DetailView): - """View to display details of a specific service instance.""" - - template_name = "frontend/organizations/service_instance_detail.html" - context_object_name = "instance" +class ServiceInstanceMixin: model = ServiceInstance - permission_type = "view" + context_object_name = "instance" slug_field = "name" + def dispatch(self, *args, **kwargs): + self._has_warned = False + return super().dispatch(*args, **kwargs) + def get_queryset(self): """Return service instance for the current organization.""" return ServiceInstance.objects.filter( @@ -169,6 +182,39 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView): "context__service_definition__service", ) + def get_object(self, **kwargs): + instance = super().get_object(**kwargs) + if ( + not instance.kubernetes_object + and not instance.is_deleted + and not self._has_warned + ): + messages.warning( + self.request, + _( + "Could not retrieve instance details from Kubernetes. It might have been deleted externally." + ), + ) + self._has_warned = True + return instance + + +class ServiceInstanceDetailView( + ServiceInstanceMixin, OrganizationViewMixin, DetailView +): + template_name = "frontend/organizations/service_instance_detail.html" + permission_type = "view" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.object.kubernetes_object and self.object.spec: + context["spec_fieldsets"] = self.get_nested_spec() + permission_required = ServiceInstance.get_perm("change") + context["has_change_permission"] = self.request.user.has_perm( + permission_required, self.object + ) + return context + def get_nested_spec(self): """ Organize spec data into fieldsets similar to how the form does it. @@ -272,12 +318,43 @@ class ServiceInstanceDetailView(OrganizationViewMixin, DetailView): return fieldsets - def get_context_data(self, **kwargs): - """Return service instance for the current organization.""" - context = super().get_context_data(**kwargs) - if self.object.spec: - context["spec_fieldsets"] = self.get_nested_spec() - return context + +class ServiceInstanceUpdateView( + ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView +): + template_name = "frontend/organizations/service_instance_update.html" + permission_type = "change" + + def get_form_class(self): + return self.object.context.model_form_class + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["instance"] = self.object.spec_object + return kwargs + + def form_valid(self, form): + try: + spec_data = form.get_nested_data().get("spec") + self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) + messages.success( + self.request, + _("Service instance '{name}' updated successfully.").format( + name=self.object.name + ), + ) + return redirect(self.object.urls.base) + except ValidationError as e: + messages.error(self.request, e.message or str(e)) + return self.form_invalid(form) + except Exception as e: + messages.error( + self.request, _("Error updating instance: {error}").format(error=str(e)) + ) + return self.form_invalid(form) + + def get_success_url(self): + return self.object.urls.base class ServiceInstanceListView(OrganizationViewMixin, ListView): @@ -293,7 +370,12 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView): def get_queryset(self): """Return all service instances for the current organization with filtering.""" queryset = ServiceInstance.objects.filter( - organization=self.request.organization + organization=self.request.organization, + is_deleted=False, # Exclude soft-deleted + ).select_related( + "context__service_offering__provider", + "context__control_plane", + "context__service_definition__service", ) if self.filter_form.is_valid(): queryset = self.filter_form.filter_queryset(queryset) diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index e2eb044..e607205 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -85,3 +85,8 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary { a.btn-keycloak { display: inline-flex; } +.page-heading h3 { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +}