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.exceptions import ValidationError
from django.db import IntegrityError, models
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 +374,13 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
@cached_property
def kind_plural(self):
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 +406,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 +422,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 +436,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 +531,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 +636,41 @@ class ServiceInstance(ServalaModelMixin, models.Model):
_("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
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 +704,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,

View file

@ -86,3 +86,35 @@ class ServiceInstanceFilterForm(forms.Form):
else:
queryset = queryset.filter(is_deleted=True)
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(),
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 (
ServiceDetailView,
ServiceInstanceDeleteView,
ServiceInstanceDetailView,
ServiceInstanceListView,
ServiceInstanceUpdateView,
@ -21,6 +22,7 @@ __all__ = [
"OrganizationDashboardView",
"OrganizationUpdateView",
"ServiceDetailView",
"ServiceInstanceDeleteView",
"ServiceInstanceDetailView",
"ServiceInstanceListView",
"ServiceInstanceUpdateView",

View file

@ -3,7 +3,7 @@ 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 django.views.generic import DetailView, UpdateView, ListView
from servala.core.crd import deslugify
from servala.core.models import (
@ -15,6 +15,7 @@ from servala.core.models import (
from servala.frontend.forms.service import (
ControlPlaneSelectForm,
ServiceFilterForm,
ServiceInstanceDeleteForm,
ServiceInstanceFilterForm,
)
from servala.frontend.views.mixins import (
@ -144,7 +145,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
if form.is_valid():
try:
service_instance = ServiceInstance.create_instance(
organization=self.organization,
organization=self.request.organization,
name=form.cleaned_data["name"],
context=self.context_object,
created_by=request.user,
@ -185,8 +186,8 @@ class ServiceInstanceMixin:
def get_object(self, **kwargs):
instance = super().get_object(**kwargs)
if (
not instance.kubernetes_object
and not instance.is_deleted
not instance.is_deleted
and not instance.kubernetes_object
and not self._has_warned
):
messages.warning(
@ -207,11 +208,13 @@ class ServiceInstanceDetailView(
def get_context_data(self, **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()
permission_required = ServiceInstance.get_perm("change")
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
@ -220,8 +223,9 @@ class ServiceInstanceDetailView(
Organize spec data into fieldsets similar to how the form does it.
"""
spec = self.object.spec or {}
if not spec:
return []
# Process spec fields
others = []
nested_fieldsets = {}
@ -379,10 +383,47 @@ class ServiceInstanceListView(OrganizationViewMixin, ListView):
)
if self.filter_form.is_valid():
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):
context = super().get_context_data(**kwargs)
context["organization"] = self.request.organization
context["filter_form"] = self.filter_form
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