Implement service instance delete basics

This commit is contained in:
Tobias Kunze 2025-05-25 22:40:01 +02:00
parent 926c9441f2
commit 09ce5406ee
6 changed files with 142 additions and 10 deletions

View file

@ -7,6 +7,7 @@ from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, models from django.db import IntegrityError, models
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField from encrypted_fields.fields import EncryptedJSONField
@ -373,7 +374,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
@cached_property @cached_property
def kind_plural(self): 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 @cached_property
def resource_definition(self): def resource_definition(self):
@ -399,8 +406,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if result := cache.get(cache_key): if result := cache.get(cache_key):
return result return result
if not self.resource_definition:
return
for v in self.resource_definition.spec.versions: for v in self.resource_definition.spec.versions:
if v.name == self.version: 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() result = v.schema.open_apiv3_schema.to_dict()
timeout_seconds = 60 * 60 * 24 timeout_seconds = 60 * 60 * 24
cache.set(cache_key, result, timeout=timeout_seconds) cache.set(cache_key, result, timeout=timeout_seconds)
@ -410,6 +422,9 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def django_model(self): def django_model(self):
from servala.core.crd import generate_django_model from servala.core.crd import generate_django_model
if not self.resource_schema:
return
kwargs = { kwargs = {
"group": self.group, "group": self.group,
"version": self.version, "version": self.version,
@ -421,6 +436,8 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
def model_form_class(self): def model_form_class(self):
from servala.core.crd import generate_model_form_class from servala.core.crd import generate_model_form_class
if not self.django_model:
return
return generate_model_form_class(self.django_model) return generate_model_form_class(self.django_model)
@ -514,6 +531,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
class urls(urlman.Urls): class urls(urlman.Urls):
base = "{self.organization.urls.instances}{self.name}/" base = "{self.organization.urls.instances}{self.name}/"
update = "{base}update/" update = "{base}update/"
delete = "{base}delete/"
def _clear_kubernetes_caches(self): def _clear_kubernetes_caches(self):
"""Clears cached properties that depend on Kubernetes state.""" """Clears cached properties that depend on Kubernetes state."""
@ -618,9 +636,41 @@ class ServiceInstance(ServalaModelMixin, models.Model):
_("Error updating instance: {error}").format(error=str(e)) _("Error updating instance: {error}").format(error=str(e))
) )
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
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()
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
@cached_property @cached_property
def kubernetes_object(self): def kubernetes_object(self):
"""Fetch the Kubernetes custom resource object""" """Fetch the Kubernetes custom resource object"""
if self.is_deleted:
return
try: try:
api_instance = client.CustomObjectsApi( api_instance = client.CustomObjectsApi(
self.context.control_plane.get_kubernetes_client() self.context.control_plane.get_kubernetes_client()
@ -654,6 +704,8 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@cached_property @cached_property
def spec_object(self): def spec_object(self):
"""Dynamically generated CRD object.""" """Dynamically generated CRD object."""
if not self.context.django_model:
return
return self.context.django_model( return self.context.django_model(
name=self.name, name=self.name,
organization=self.organization, organization=self.organization,

View file

@ -86,3 +86,35 @@ class ServiceInstanceFilterForm(forms.Form):
else: else:
queryset = queryset.filter(is_deleted=True) queryset = queryset.filter(is_deleted=True)
return queryset return queryset
class ServiceInstanceDeleteForm(forms.Form):
name_confirm = forms.CharField(
label=_("Instance Name"),
max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
def __init__(self, *args, **kwargs):
self.instance_name = kwargs.pop("instance_name", None)
super().__init__(*args, **kwargs)
if self.instance_name:
self.fields["name_confirm"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>"
).format(instance_name=self.instance_name)
else:
self.fields["name_confirm"].help_text = _(
"Please type the instance name to confirm deletion."
)
def clean_name_confirm(self):
entered_name = self.cleaned_data.get("name_confirm")
if not self.instance_name:
raise ValidationError(
_("Cannot confirm deletion: instance name not provided to form.")
)
if entered_name != self.instance_name:
raise ValidationError(
_("The entered name does not match the instance name. Deletion not confirmed.")
)
return entered_name

View file

@ -55,6 +55,11 @@ urlpatterns = [
views.ServiceInstanceUpdateView.as_view(), views.ServiceInstanceUpdateView.as_view(),
name="organization.instance.update", name="organization.instance.update",
), ),
path(
"instances/<slug:slug>/delete/",
views.ServiceInstanceDeleteView.as_view(),
name="organization.instance.delete",
),
] ]
), ),
), ),

View file

@ -7,6 +7,7 @@ from .organization import (
) )
from .service import ( from .service import (
ServiceDetailView, ServiceDetailView,
ServiceInstanceDeleteView,
ServiceInstanceDetailView, ServiceInstanceDetailView,
ServiceInstanceListView, ServiceInstanceListView,
ServiceInstanceUpdateView, ServiceInstanceUpdateView,
@ -21,6 +22,7 @@ __all__ = [
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView", "ServiceDetailView",
"ServiceInstanceDeleteView",
"ServiceInstanceDetailView", "ServiceInstanceDetailView",
"ServiceInstanceListView", "ServiceInstanceListView",
"ServiceInstanceUpdateView", "ServiceInstanceUpdateView",

View file

@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, UpdateView, ListView
from servala.core.crd import deslugify from servala.core.crd import deslugify
from servala.core.models import ( from servala.core.models import (
@ -15,6 +15,7 @@ from servala.core.models import (
from servala.frontend.forms.service import ( from servala.frontend.forms.service import (
ControlPlaneSelectForm, ControlPlaneSelectForm,
ServiceFilterForm, ServiceFilterForm,
ServiceInstanceDeleteForm,
ServiceInstanceFilterForm, ServiceInstanceFilterForm,
) )
from servala.frontend.views.mixins import ( from servala.frontend.views.mixins import (
@ -144,7 +145,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
if form.is_valid(): if form.is_valid():
try: try:
service_instance = ServiceInstance.create_instance( service_instance = ServiceInstance.create_instance(
organization=self.organization, organization=self.request.organization,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
context=self.context_object, context=self.context_object,
created_by=request.user, created_by=request.user,
@ -185,8 +186,8 @@ class ServiceInstanceMixin:
def get_object(self, **kwargs): def get_object(self, **kwargs):
instance = super().get_object(**kwargs) instance = super().get_object(**kwargs)
if ( if (
not instance.kubernetes_object not instance.is_deleted
and not instance.is_deleted and not instance.kubernetes_object
and not self._has_warned and not self._has_warned
): ):
messages.warning( messages.warning(
@ -207,11 +208,13 @@ class ServiceInstanceDetailView(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if self.object.kubernetes_object and self.object.spec: if not self.object.is_deleted and self.object.kubernetes_object and self.object.spec:
context["spec_fieldsets"] = self.get_nested_spec() context["spec_fieldsets"] = self.get_nested_spec()
permission_required = ServiceInstance.get_perm("change")
context["has_change_permission"] = self.request.user.has_perm( context["has_change_permission"] = self.request.user.has_perm(
permission_required, self.object ServiceInstance.get_perm("change"), self.object
)
context["has_delete_permission"] = self.request.user.has_perm(
ServiceInstance.get_perm("delete"), self.object
) )
return context return context
@ -220,8 +223,9 @@ class ServiceInstanceDetailView(
Organize spec data into fieldsets similar to how the form does it. Organize spec data into fieldsets similar to how the form does it.
""" """
spec = self.object.spec or {} spec = self.object.spec or {}
if not spec:
return []
# Process spec fields
others = [] others = []
nested_fieldsets = {} nested_fieldsets = {}
@ -379,10 +383,47 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
) )
if self.filter_form.is_valid(): if self.filter_form.is_valid():
queryset = self.filter_form.filter_queryset(queryset) queryset = self.filter_form.filter_queryset(queryset)
return queryset status_filter = self.filter_form.cleaned_data.get("status") if self.filter_form.is_valid() else "active"
if status_filter == "active":
queryset = queryset.filter(is_deleted=False)
elif status_filter == "deleted":
queryset = queryset.filter(is_deleted=True)
return queryset.order_by("-created_at")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["organization"] = self.request.organization context["organization"] = self.request.organization
context["filter_form"] = self.filter_form context["filter_form"] = self.filter_form
return context return context
class ServiceInstanceDeleteView(
ServiceInstanceMixin, OrganizationViewMixin, HtmxViewMixin, UpdateView
):
template_name = "frontend/organizations/service_instance_delete_form.html"
form_class = ServiceInstanceDeleteForm
permission_type = "delete"
def form_valid(self, form):
try:
self.object.delete_instance(user=self.request.user)
messages.success(
self.request,
_("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name
),
)
response = HttpResponse()
response["HX-Redirect"] = self.get_success_url()
return response
except Exception as e:
messages.error(
self.request,
_("An error occurred while trying to delete instance '{name}': {error}").format(
name=self.object.name, error=str(e)
),
)
return self.form_invalid(form)
def get_success_url(self):
return self.request.organization.urls.instances