diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 901a822..ba57c9c 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,3 +1,4 @@ +import copy import json import kubernetes @@ -6,7 +7,8 @@ import urlman from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError -from django.db import IntegrityError, models +from django.db import IntegrityError, models, transaction +from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from encrypted_fields.fields import EncryptedJSONField @@ -373,7 +375,15 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): @cached_property def kind_plural(self): - return self.resource_definition.status.accepted_names.plural + if ( + hasattr(self.resource_definition, "status") + and hasattr(self.resource_definition.status, "accepted_names") + and self.resource_definition.status.accepted_names + ): + return self.resource_definition.status.accepted_names.plural + if self.kind.endswith("s"): + return self.kind.lower() + return f"{self.kind.lower()}s" @cached_property def resource_definition(self): @@ -399,8 +409,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if result := cache.get(cache_key): return result + if not self.resource_definition: + return + for v in self.resource_definition.spec.versions: if v.name == self.version: + if not v.schema or not v.schema.open_apiv3_schema: + return result = v.schema.open_apiv3_schema.to_dict() timeout_seconds = 60 * 60 * 24 cache.set(cache_key, result, timeout=timeout_seconds) @@ -410,6 +425,9 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): def django_model(self): from servala.core.crd import generate_django_model + if not self.resource_schema: + return + kwargs = { "group": self.group, "version": self.version, @@ -421,6 +439,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): def model_form_class(self): from servala.core.crd import generate_model_form_class + if not self.django_model: + return return generate_model_form_class(self.django_model) @@ -514,6 +534,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): class urls(urlman.Urls): base = "{self.organization.urls.instances}{self.name}/" update = "{base}update/" + delete = "{base}delete/" def _clear_kubernetes_caches(self): """Clears cached properties that depend on Kubernetes state.""" @@ -618,9 +639,51 @@ class ServiceInstance(ServalaModelMixin, models.Model): _("Error updating instance: {error}").format(error=str(e)) ) + @transaction.atomic + def delete_instance(self, user): + """ + Soft deletes the instance in Django and initiates deletion of the + corresponding Kubernetes custom resource. + """ + if self.is_deleted: + return + + if ( + self.spec.get("parameters", {}) + .get("security", {}) + .get("deletionProtection") + ): + spec = copy.copy(self.spec) + spec["parameters"]["security"]["deletionProtection"] = False + self.update_spec(spec, user) + + try: + api_instance = self.context.control_plane.custom_objects_api + api_instance.delete_namespaced_custom_object( + group=self.context.group, + version=self.context.version, + namespace=self.organization.namespace, + plural=self.context.kind_plural, + name=self.name, + body=client.V1DeleteOptions(), + ) + except ApiException as e: + if e.status != 404: + # 404 is fine, the object was deleted already. + raise + self.is_deleted = True + self.deleted_at = timezone.now() + self.deleted_by = user + self.save( + update_fields=["is_deleted", "deleted_at", "deleted_by", "updated_at"] + ) + self._clear_kubernetes_caches() + @cached_property def kubernetes_object(self): """Fetch the Kubernetes custom resource object""" + if self.is_deleted: + return try: api_instance = client.CustomObjectsApi( self.context.control_plane.get_kubernetes_client() @@ -654,6 +717,8 @@ class ServiceInstance(ServalaModelMixin, models.Model): @cached_property def spec_object(self): """Dynamically generated CRD object.""" + if not self.context.django_model: + return return self.context.django_model( name=self.name, organization=self.organization, diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 1b134a2..04fe2df 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -6,6 +6,7 @@ from servala.core.models import ( ControlPlane, Service, ServiceCategory, + ServiceInstance, ServiceOffering, ) @@ -37,6 +38,8 @@ class ControlPlaneSelectForm(forms.Form): def __init__(self, *args, planes=None, **kwargs): super().__init__(*args, **kwargs) self.fields["control_plane"].queryset = planes + if planes and planes.count() == 1: + self.fields["control_plane"].initial = planes.first() class ServiceInstanceFilterForm(forms.Form): @@ -68,21 +71,50 @@ class ServiceInstanceFilterForm(forms.Form): def filter_queryset(self, queryset): if self.is_valid(): data = self.cleaned_data - if data["name"]: + if data.get("name"): queryset = queryset.filter(name__icontains=data["name"]) - if data["service"]: + if data.get("service"): queryset = queryset.filter( context__service_definition__service=data["service"] ) - if data["provider"]: + if data.get("provider"): queryset = queryset.filter( context__service_offering__provider=data["provider"] ) - if data["control_plane"]: + if data.get("control_plane"): queryset = queryset.filter(context__control_plane=data["control_plane"]) - if data["status"]: - if data["status"] == "active": - queryset = queryset.filter(is_deleted=False) - else: - queryset = queryset.filter(is_deleted=True) + status = data.get("status") + if status == "active": + queryset = queryset.filter(is_deleted=False) + elif status == "deleted": + queryset = queryset.filter(is_deleted=True) return queryset + + +class ServiceInstanceDeleteForm(forms.ModelForm): + name = forms.CharField( + label=_("Instance Name"), + max_length=63, + widget=forms.TextInput(attrs={"class": "form-control"}), + ) + + def __init__(self, *args, **kwargs): + kwargs["initial"] = {"name": ""} + super().__init__(*args, **kwargs) + self.fields["name"].help_text = _( + "To confirm deletion, please type the instance name: {instance_name}" + ).format(instance_name=self.instance.name) + + def clean_name(self): + entered_name = self.cleaned_data.get("name") + if entered_name != self.instance.name: + raise forms.ValidationError( + _( + "The entered name does not match the instance name. Deletion not confirmed." + ) + ) + return entered_name + + class Meta: + model = ServiceInstance + fields = ("name",) diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index b45c41f..8ab90d1 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,6 +1,6 @@ {% load i18n %} {% if form.non_field_errors or form.errors %} -
{{ field.value|pprint }}{% else %} - {{ field.value }} + {{ field.value|default:"-" }} {% endif %} {% endfor %} @@ -150,13 +169,15 @@ {% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
{{ field.value|pprint }}{% else %} - {{ field.value }} + {{ field.value|default:"-" }} {% endif %} {% endfor %} {% endfor %}
{% translate "No specification details to display." %}
{% endfor %}