Implement service instance delete basics
This commit is contained in:
parent
926c9441f2
commit
09ce5406ee
6 changed files with 142 additions and 10 deletions
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue