Ctrl K
ComunidadGuías…

api/handler/resource_deletion.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handler

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"net/http"
	"sort"
	"strings"
	"time"

	"github.com/goodrain/rainbond/api/model"
	"github.com/goodrain/rainbond/api/util"
	"github.com/goodrain/rainbond/db"
	dbmodel "github.com/goodrain/rainbond/db/model"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
	"k8s.io/apimachinery/pkg/util/wait"
	utilyaml "k8s.io/apimachinery/pkg/util/yaml"
	"k8s.io/client-go/dynamic"
)

var (
	// ErrCRDCascadeConfirmationRequired prevents an implicit cluster-wide CR deletion.
	ErrCRDCascadeConfirmationRequired = errors.New("deleting the CRD affects resources outside the current application; explicit cascade confirmation is required")
	// ErrK8sResourceDeletionNotConfirmed indicates that Kubernetes still reports a resource.
	ErrK8sResourceDeletionNotConfirmed = errors.New("Kubernetes resource deletion could not be confirmed")
	// ErrInvalidK8sResourceDeletionRequest identifies malformed user-supplied resource metadata.
	ErrInvalidK8sResourceDeletionRequest = errors.New("invalid Kubernetes resource deletion request")
	crdGVR                               = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"}
)

const (
	appIDLabel     = "app_id"
	managedByLabel = "app.kubernetes.io/managed-by"
)

const finalDeletionCheckTimeout = 3 * time.Second

type k8sResourceDeletionOrchestrator struct {
	dynamicClient dynamic.Interface
	mapper        meta.RESTMapper
	refreshMapper func() (meta.RESTMapper, error)
	waitInterval  time.Duration
	waitTimeout   time.Duration
}

type plannedResource struct {
	item       model.HandleResource
	object     *unstructured.Unstructured
	mapping    *meta.RESTMapping
	apiRemoved bool
}

type plannedCRD struct {
	resource   plannedResource
	impact     model.CRDDeletionImpact
	gvr        schema.GroupVersionResource
	namespaced bool
	instances  []unstructured.Unstructured
}

type k8sResourceDeletionPlan struct {
	resources []plannedResource
	crds      []plannedCRD
	impact    model.K8sResourceDeletionImpact
}

func (c *clusterAction) deletionOrchestrator() *k8sResourceDeletionOrchestrator {
	return &k8sResourceDeletionOrchestrator{
		dynamicClient: c.dynamicClient,
		mapper:        c.mapper,
		refreshMapper: func() (meta.RESTMapper, error) {
			mapper, err := RefreshMapper(c.clientset)
			if err == nil {
				c.mapper = mapper
			}
			return mapper, err
		},
	}
}

// PreviewK8SResourceDeletion returns cluster-wide CRD impact without mutation.
func (c *clusterAction) PreviewK8SResourceDeletion(ctx context.Context, req *model.K8sResourceDeletionRequest) (*model.K8sResourceDeletionImpact, *util.APIHandleError) {
	impact, err := c.deletionOrchestrator().Preview(ctx, req)
	if err != nil {
		if errors.Is(err, ErrInvalidK8sResourceDeletionRequest) {
			return nil, util.CreateAPIHandleError(http.StatusBadRequest, err)
		}
		return nil, util.CreateAPIHandleError(http.StatusInternalServerError, err)
	}
	return impact, nil
}

// DeleteK8SResources performs a confirmed, ordered resource deletion and then removes Region metadata.
func (c *clusterAction) DeleteK8SResources(ctx context.Context, req *model.K8sResourceDeletionRequest) (*model.K8sResourceDeletionResult, *util.APIHandleError) {
	result, err := c.deletionOrchestrator().Delete(ctx, req)
	if err != nil {
		if errors.Is(err, ErrInvalidK8sResourceDeletionRequest) {
			return nil, util.CreateAPIHandleError(http.StatusBadRequest, err)
		}
		if errors.Is(err, ErrCRDCascadeConfirmationRequired) || errors.Is(err, ErrK8sResourceDeletionNotConfirmed) {
			return nil, util.CreateAPIHandleError(http.StatusConflict, err)
		}
		return nil, util.CreateAPIHandleError(http.StatusInternalServerError, err)
	}
	if err := cleanupDeletedResourceMetadata(req, result); err != nil {
		return nil, util.CreateAPIHandleError(http.StatusInternalServerError, fmt.Errorf("clean Region Kubernetes resource metadata: %w", err))
	}
	return result, nil
}

// ReconcileK8SResources removes Region metadata only for resources confirmed absent from Kubernetes.
func (c *clusterAction) ReconcileK8SResources(ctx context.Context, req *model.K8sResourceReconcileRequest) (*model.K8sResourceReconcileResult, *util.APIHandleError) {
	result := c.deletionOrchestrator().Reconcile(ctx, req)
	missing := make(map[string]struct{}, len(result.MissingClientIDs))
	for _, clientID := range result.MissingClientIDs {
		missing[clientID] = struct{}{}
	}
	for _, resource := range req.K8sResources {
		if _, ok := missing[resource.ClientID]; !ok {
			continue
		}
		if err := db.GetManager().K8sResourceDao().DeleteK8sResource(req.AppID, resource.Name, resource.Kind); err != nil {
			return nil, util.CreateAPIHandleError(http.StatusInternalServerError, fmt.Errorf("clean Region Kubernetes resource metadata: %w", err))
		}
	}
	return result, nil
}

// Preview builds a deletion plan without mutating Kubernetes resources.
func (o *k8sResourceDeletionOrchestrator) Preview(ctx context.Context, req *model.K8sResourceDeletionRequest) (*model.K8sResourceDeletionImpact, error) {
	plan, err := o.buildPlan(ctx, req)
	if err != nil {
		return nil, err
	}
	return &plan.impact, nil
}

// Delete stops in-application generators of non-finalized custom resources,
// then removes dependent custom resources, ordinary resources, and CRDs.
func (o *k8sResourceDeletionOrchestrator) Delete(ctx context.Context, req *model.K8sResourceDeletionRequest) (*model.K8sResourceDeletionResult, error) {
	plan, err := o.buildPlan(ctx, req)
	if err != nil {
		return nil, err
	}
	if plan.impact.RequiresCascade && !req.CascadeCRD {
		return nil, ErrCRDCascadeConfirmationRequired
	}

	deletedResources := make(map[int]struct{})
	for _, index := range controllerResourcesToStopBeforeCustomResources(plan) {
		propagation := metav1.DeletePropagationForeground
		if err := o.deleteResourceAndWaitWithOptions(ctx, &plan.resources[index], metav1.DeleteOptions{
			PropagationPolicy: &propagation,
		}); err != nil {
			return nil, err
		}
		deletedResources[index] = struct{}{}
	}

	for i := range plan.crds {
		if err := o.deleteCustomResources(ctx, &plan.crds[i]); err != nil {
			return nil, err
		}
	}
	for i := range plan.resources {
		if _, deleted := deletedResources[i]; deleted {
			continue
		}
		resource := &plan.resources[i]
		if resource.item.State != model.CreateSuccess && resource.item.State != model.UpdateSuccess {
			continue
		}
		if resource.apiRemoved || resource.object == nil || isCRD(resource.object.GroupVersionKind()) {
			continue
		}
		if err := o.deleteResourceAndWait(ctx, resource); err != nil {
			return nil, err
		}
	}
	for i := range plan.crds {
		if err := o.deleteCRDAndWait(ctx, &plan.crds[i]); err != nil {
			return nil, err
		}
	}

	result := &model.K8sResourceDeletionResult{
		Status:           "completed",
		DeletedClientIDs: make([]string, 0, len(req.K8sResources)),
		CascadedCRDs:     plan.impact.CRDs,
	}
	for _, resource := range req.K8sResources {
		result.DeletedClientIDs = append(result.DeletedClientIDs, resource.ClientID)
	}
	return result, nil
}

func controllerResourcesToStopBeforeCustomResources(plan *k8sResourceDeletionPlan) []int {
	candidateManagers := make(map[string]struct{})
	finalizedManagers := make(map[string]struct{})
	for i := range plan.crds {
		for j := range plan.crds[i].instances {
			instance := &plan.crds[i].instances[j]
			manager := strings.ToLower(strings.TrimSpace(instance.GetLabels()[managedByLabel]))
			if manager == "" {
				continue
			}
			if len(instance.GetFinalizers()) > 0 {
				finalizedManagers[manager] = struct{}{}
				continue
			}
			candidateManagers[manager] = struct{}{}
		}
	}

	var indexes []int
	for i := range plan.resources {
		resource := &plan.resources[i]
		if resource.item.State != model.CreateSuccess && resource.item.State != model.UpdateSuccess ||
			resource.apiRemoved || resource.object == nil || !isControllerWorkload(resource.object.GroupVersionKind()) {
			continue
		}
		name := strings.ToLower(strings.TrimSpace(resource.object.GetName()))
		if name == "" {
			name = strings.ToLower(strings.TrimSpace(resource.item.Name))
		}
		if _, blocked := finalizedManagers[name]; blocked {
			continue
		}
		if _, matched := candidateManagers[name]; matched {
			indexes = append(indexes, i)
		}
	}
	return indexes
}

func isControllerWorkload(gvk schema.GroupVersionKind) bool {
	if gvk.Group != "apps" {
		return false
	}
	switch gvk.Kind {
	case "Deployment", "StatefulSet", "DaemonSet":
		return true
	default:
		return false
	}
}

// Reconcile classifies only confirmed missing resources as safe metadata deletions.
func (o *k8sResourceDeletionOrchestrator) Reconcile(ctx context.Context, req *model.K8sResourceReconcileRequest) *model.K8sResourceReconcileResult {
	result := &model.K8sResourceReconcileResult{
		MissingClientIDs: []string{},
		Unknown:          []model.K8sResourceUnknown{},
	}
	for _, item := range req.K8sResources {
		if item.State != model.CreateSuccess && item.State != model.UpdateSuccess {
			continue
		}
		obj, err := decodeSingleResource(item.ResourceYaml)
		if err != nil {
			result.Unknown = append(result.Unknown, model.K8sResourceUnknown{ClientID: item.ClientID, Error: err.Error()})
			continue
		}
		mapping, apiRemoved, err := o.resolveMapping(obj.GroupVersionKind())
		if err != nil {
			result.Unknown = append(result.Unknown, model.K8sResourceUnknown{ClientID: item.ClientID, Error: err.Error()})
			continue
		}
		if apiRemoved {
			result.MissingClientIDs = append(result.MissingClientIDs, item.ClientID)
			continue
		}
		name := item.Name
		if name == "" {
			name = obj.GetName()
		}
		resourceClient := o.resourceClient(mapping, item.Namespace, obj.GetNamespace())
		_, err = resourceClient.Get(ctx, name, metav1.GetOptions{})
		switch {
		case apierrors.IsNotFound(err):
			result.MissingClientIDs = append(result.MissingClientIDs, item.ClientID)
		case err != nil:
			result.Unknown = append(result.Unknown, model.K8sResourceUnknown{ClientID: item.ClientID, Error: err.Error()})
		}
	}
	return result
}

func (o *k8sResourceDeletionOrchestrator) buildPlan(ctx context.Context, req *model.K8sResourceDeletionRequest) (*k8sResourceDeletionPlan, error) {
	if req == nil || strings.TrimSpace(req.AppID) == "" {
		return nil, fmt.Errorf("%w: app_id is required", ErrInvalidK8sResourceDeletionRequest)
	}
	plan := &k8sResourceDeletionPlan{
		impact: model.K8sResourceDeletionImpact{CRDs: []model.CRDDeletionImpact{}},
	}
	plannedCRDs := make(map[string]struct{})
	for _, item := range req.K8sResources {
		if item.State != model.CreateSuccess && item.State != model.UpdateSuccess {
			plan.resources = append(plan.resources, plannedResource{item: item})
			continue
		}
		obj, err := decodeSingleResource(item.ResourceYaml)
		if err != nil {
			return nil, fmt.Errorf("%w: decode %s/%s: %v", ErrInvalidK8sResourceDeletionRequest, item.Kind, item.Name, err)
		}
		mapping, apiRemoved, err := o.resolveMapping(obj.GroupVersionKind())
		if err != nil {
			return nil, fmt.Errorf("resolve %s/%s: %w", item.Kind, item.Name, err)
		}
		resource := plannedResource{item: item, object: obj, mapping: mapping, apiRemoved: apiRemoved}
		plan.resources = append(plan.resources, resource)
		if !isCRD(obj.GroupVersionKind()) || apiRemoved {
			continue
		}
		if _, ok := plannedCRDs[obj.GetName()]; ok {
			continue
		}
		plannedCRDs[obj.GetName()] = struct{}{}
		crd, err := o.buildCRDPlan(ctx, resource, req.AppID)
		if apierrors.IsNotFound(err) {
			continue
		}
		if err != nil {
			return nil, err
		}
		plan.crds = append(plan.crds, *crd)
	}

	plan.impact.CRDCount = len(plan.crds)
	plan.impact.HasCRD = plan.impact.CRDCount > 0
	affectedApps := make(map[string]struct{})
	for _, crd := range plan.crds {
		plan.impact.CRDs = append(plan.impact.CRDs, crd.impact)
		plan.impact.CRCount += crd.impact.CurrentAppCRCount + crd.impact.OtherAppCRCount + crd.impact.UnownedCRCount
		plan.impact.UnownedCRCount += crd.impact.UnownedCRCount
		if crd.impact.OtherAppCRCount > 0 || crd.impact.UnownedCRCount > 0 {
			plan.impact.RequiresCascade = true
		}
		for _, appID := range crd.impact.AffectedRegionAppIDs {
			affectedApps[appID] = struct{}{}
		}
	}
	plan.impact.OtherAppCount = len(affectedApps)
	return plan, nil
}

func (o *k8sResourceDeletionOrchestrator) buildCRDPlan(ctx context.Context, resource plannedResource, currentAppID string) (*plannedCRD, error) {
	live, err := o.dynamicClient.Resource(crdGVR).Get(ctx, resource.object.GetName(), metav1.GetOptions{})
	if err != nil {
		return nil, err
	}
	group, found, err := unstructured.NestedString(live.Object, "spec", "group")
	if err != nil || !found || group == "" {
		return nil, fmt.Errorf("CRD %s has no spec.group", live.GetName())
	}
	plural, found, err := unstructured.NestedString(live.Object, "spec", "names", "plural")
	if err != nil || !found || plural == "" {
		return nil, fmt.Errorf("CRD %s has no spec.names.plural", live.GetName())
	}
	kind, found, err := unstructured.NestedString(live.Object, "spec", "names", "kind")
	if err != nil || !found || kind == "" {
		return nil, fmt.Errorf("CRD %s has no spec.names.kind", live.GetName())
	}
	scope, found, err := unstructured.NestedString(live.Object, "spec", "scope")
	if err != nil || !found || scope == "" {
		return nil, fmt.Errorf("CRD %s has no spec.scope", live.GetName())
	}
	version, err := crdStorageVersion(live)
	if err != nil {
		return nil, err
	}
	gvr := schema.GroupVersionResource{Group: group, Version: version, Resource: plural}
	list, err := o.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
	if err != nil {
		return nil, fmt.Errorf("list custom resources for CRD %s: %w", live.GetName(), err)
	}

	impact := model.CRDDeletionImpact{
		Name:                 live.GetName(),
		Group:                group,
		Version:              version,
		Kind:                 kind,
		Plural:               plural,
		Scope:                scope,
		AffectedRegionAppIDs: []string{},
	}
	otherApps := make(map[string]struct{})
	for i := range list.Items {
		appID := list.Items[i].GetLabels()[appIDLabel]
		switch {
		case appID == "":
			impact.UnownedCRCount++
		case appID == currentAppID:
			impact.CurrentAppCRCount++
		default:
			impact.OtherAppCRCount++
			otherApps[appID] = struct{}{}
		}
	}
	for appID := range otherApps {
		impact.AffectedRegionAppIDs = append(impact.AffectedRegionAppIDs, appID)
	}
	sort.Strings(impact.AffectedRegionAppIDs)
	return &plannedCRD{
		resource:   resource,
		impact:     impact,
		gvr:        gvr,
		namespaced: strings.EqualFold(scope, "Namespaced"),
		instances:  list.Items,
	}, nil
}

func (o *k8sResourceDeletionOrchestrator) deleteCustomResources(ctx context.Context, crd *plannedCRD) error {
	for i := range crd.instances {
		instance := &crd.instances[i]
		resourceClient := o.dynamicClient.Resource(crd.gvr)
		var client dynamic.ResourceInterface = resourceClient
		if crd.namespaced {
			client = resourceClient.Namespace(instance.GetNamespace())
		}
		if err := client.Delete(ctx, instance.GetName(), metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
			return fmt.Errorf("delete custom resource %s/%s for CRD %s: %w", instance.GetNamespace(), instance.GetName(), crd.impact.Name, err)
		}
	}
	var checkErr error
	err := wait.PollUntilContextTimeout(ctx, o.interval(), o.timeout(), true, func(ctx context.Context) (bool, error) {
		list, err := o.dynamicClient.Resource(crd.gvr).List(ctx, metav1.ListOptions{})
		if err != nil {
			checkErr = err
			return false, err
		}
		return len(list.Items) == 0, nil
	})
	if err != nil {
		finalCheckCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finalDeletionCheckTimeout)
		defer cancel()
		remaining, finalCheckErr := o.dynamicClient.Resource(crd.gvr).List(finalCheckCtx, metav1.ListOptions{})
		if finalCheckErr == nil && len(remaining.Items) == 0 {
			return nil
		}
		if checkErr != nil {
			return fmt.Errorf("confirm custom resources for CRD %s: %w", crd.impact.Name, checkErr)
		}
		if finalCheckErr != nil {
			return fmt.Errorf("final confirmation of custom resources for CRD %s: %w", crd.impact.Name, finalCheckErr)
		}
		return fmt.Errorf("%w for CRD %s custom resources: %v", ErrK8sResourceDeletionNotConfirmed, crd.impact.Name, err)
	}
	return nil
}

func (o *k8sResourceDeletionOrchestrator) deleteResourceAndWait(ctx context.Context, resource *plannedResource) error {
	return o.deleteResourceAndWaitWithOptions(ctx, resource, metav1.DeleteOptions{})
}

func (o *k8sResourceDeletionOrchestrator) deleteResourceAndWaitWithOptions(ctx context.Context, resource *plannedResource, options metav1.DeleteOptions) error {
	name := resource.item.Name
	if name == "" {
		name = resource.object.GetName()
	}
	client := o.resourceClient(resource.mapping, resource.item.Namespace, resource.object.GetNamespace())
	if err := client.Delete(ctx, name, options); err != nil && !apierrors.IsNotFound(err) {
		return fmt.Errorf("delete %s/%s: %w", resource.object.GetKind(), name, err)
	}
	var checkErr error
	err := wait.PollUntilContextTimeout(ctx, o.interval(), o.timeout(), true, func(ctx context.Context) (bool, error) {
		_, err := client.Get(ctx, name, metav1.GetOptions{})
		if apierrors.IsNotFound(err) {
			return true, nil
		}
		if err != nil {
			checkErr = err
		}
		return false, err
	})
	if err != nil {
		if checkErr != nil {
			return fmt.Errorf("confirm %s/%s deletion: %w", resource.object.GetKind(), name, checkErr)
		}
		return fmt.Errorf("%w for %s/%s: %v", ErrK8sResourceDeletionNotConfirmed, resource.object.GetKind(), name, err)
	}
	return nil
}

func (o *k8sResourceDeletionOrchestrator) deleteCRDAndWait(ctx context.Context, crd *plannedCRD) error {
	if err := o.dynamicClient.Resource(crdGVR).Delete(ctx, crd.impact.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
		return fmt.Errorf("delete CRD %s: %w", crd.impact.Name, err)
	}
	var checkErr error
	err := wait.PollUntilContextTimeout(ctx, o.interval(), o.timeout(), true, func(ctx context.Context) (bool, error) {
		_, err := o.dynamicClient.Resource(crdGVR).Get(ctx, crd.impact.Name, metav1.GetOptions{})
		if apierrors.IsNotFound(err) {
			return true, nil
		}
		if err != nil {
			checkErr = err
		}
		return false, err
	})
	if err != nil {
		if checkErr != nil {
			return fmt.Errorf("confirm CRD %s deletion: %w", crd.impact.Name, checkErr)
		}
		return fmt.Errorf("%w for CRD %s: %v", ErrK8sResourceDeletionNotConfirmed, crd.impact.Name, err)
	}
	return nil
}

func (o *k8sResourceDeletionOrchestrator) resolveMapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, bool, error) {
	mapping, err := o.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
	if err == nil {
		return mapping, false, nil
	}
	if !meta.IsNoMatchError(err) {
		return nil, false, err
	}
	if o.refreshMapper == nil {
		return nil, false, err
	}
	mapper, refreshErr := o.refreshMapper()
	if refreshErr != nil {
		return nil, false, refreshErr
	}
	o.mapper = mapper
	mapping, err = mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
	if meta.IsNoMatchError(err) {
		return nil, true, nil
	}
	return mapping, false, err
}

func (o *k8sResourceDeletionOrchestrator) resourceClient(mapping *meta.RESTMapping, requestedNamespace, objectNamespace string) dynamic.ResourceInterface {
	resourceClient := o.dynamicClient.Resource(mapping.Resource)
	if mapping.Scope.Name() != meta.RESTScopeNameNamespace {
		return resourceClient
	}
	namespace := requestedNamespace
	if namespace == "" {
		namespace = objectNamespace
	}
	return resourceClient.Namespace(namespace)
}

func (o *k8sResourceDeletionOrchestrator) interval() time.Duration {
	if o.waitInterval <= 0 {
		return time.Second
	}
	return o.waitInterval
}

func (o *k8sResourceDeletionOrchestrator) timeout() time.Duration {
	if o.waitTimeout <= 0 {
		return time.Minute
	}
	return o.waitTimeout
}

func decodeSingleResource(content string) (*unstructured.Unstructured, error) {
	decoder := utilyaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(content)), 4096)
	var raw runtime.RawExtension
	if err := decoder.Decode(&raw); err != nil {
		return nil, err
	}
	obj, _, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(raw.Raw, nil, nil)
	if err != nil {
		return nil, err
	}
	unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
	if err != nil {
		return nil, err
	}
	resource := &unstructured.Unstructured{Object: unstructuredMap}
	if resource.GetAPIVersion() == "" || resource.GetKind() == "" || resource.GetName() == "" {
		return nil, fmt.Errorf("apiVersion, kind, and metadata.name are required")
	}
	return resource, nil
}

func crdStorageVersion(crd *unstructured.Unstructured) (string, error) {
	versions, found, err := unstructured.NestedSlice(crd.Object, "spec", "versions")
	if err != nil {
		return "", err
	}
	var firstServed string
	if found {
		for _, rawVersion := range versions {
			version, ok := rawVersion.(map[string]interface{})
			if !ok {
				continue
			}
			storage, _, _ := unstructured.NestedBool(version, "storage")
			served, _, _ := unstructured.NestedBool(version, "served")
			name, _, _ := unstructured.NestedString(version, "name")
			if served && firstServed == "" {
				firstServed = name
			}
			if storage && served && name != "" {
				return name, nil
			}
		}
		if firstServed != "" {
			return firstServed, nil
		}
	}
	version, found, err := unstructured.NestedString(crd.Object, "spec", "version")
	if err == nil && found && version != "" {
		return version, nil
	}
	return "", fmt.Errorf("CRD %s has no storage version", crd.GetName())
}

func isCRD(gvk schema.GroupVersionKind) bool {
	return gvk.Group == crdGVR.Group && gvk.Kind == "CustomResourceDefinition"
}

func cleanupDeletedResourceMetadata(req *model.K8sResourceDeletionRequest, result *model.K8sResourceDeletionResult) error {
	dao := db.GetManager().K8sResourceDao()
	appResources, err := dao.ListByAppID(req.AppID)
	if err != nil {
		return err
	}
	selected := make(map[string]struct{}, len(req.K8sResources))
	for _, resource := range req.K8sResources {
		selected[resource.Name+"\x00"+resource.Kind] = struct{}{}
	}
	ids := make(map[uint]struct{})
	for _, resource := range appResources {
		if _, ok := selected[resource.Name+"\x00"+resource.Kind]; ok {
			ids[resource.ID] = struct{}{}
		}
	}
	for _, impact := range result.CascadedCRDs {
		resources, err := dao.ListByKind(impact.Kind)
		if err != nil {
			return err
		}
		for _, id := range matchingCRDMetadataIDs(resources, []model.CRDDeletionImpact{impact}) {
			ids[id] = struct{}{}
		}
	}
	if len(ids) == 0 {
		return nil
	}
	deleteIDs := make([]uint, 0, len(ids))
	for id := range ids {
		deleteIDs = append(deleteIDs, id)
	}
	sort.Slice(deleteIDs, func(i, j int) bool { return deleteIDs[i] < deleteIDs[j] })
	return dao.DeleteK8sResourceByIDs(deleteIDs)
}

func matchingCRDMetadataIDs(resources []dbmodel.K8sResource, impacts []model.CRDDeletionImpact) []uint {
	var ids []uint
	for _, resource := range resources {
		group, kind, err := resourceGroupKind(resource.Content)
		if err != nil {
			continue
		}
		for _, impact := range impacts {
			if group == impact.Group && kind == impact.Kind {
				ids = append(ids, resource.ID)
				break
			}
		}
	}
	return ids
}

func resourceGroupKind(content string) (string, string, error) {
	obj, err := decodeSingleResource(content)
	if err != nil {
		return "", "", err
	}
	return obj.GroupVersionKind().Group, obj.GetKind(), nil
}