Repository: yonahd/kor
Pull request #172 (2023-11-30 15:49:17)

Add finalizer subcommand
Iterates over all types of namespaced resources in the cluster (Including Custom Resources) check if they are in a terminating state by looking if they have a finalizer and a deletion timestamp, deletion of those resources is also supported by patching out the finalizer.

part of #117 

this command is not added to the "all" command for future plans implementing a separate function for the supported resources after #167 



yonahd: @Yuni-sa is this ready for review? 
Yuni-sa: > Overall it looks good. Can we support this in multi and all and merge the namespaces? Maybe we can do it that way and then put finalizers as include finalizers flag or something like that?
> 
> WDYT?

Not sure if there is a good implementation of this without #167 , after reasoning will be added I will create a new pr that will include it in all and multi.

if we add this now it would probably all be deleted/changed in the next pr, I think the current implementation of using `kor finalizers` which also supports all of the filters options is fine for now.
yonahd: > > Overall it looks good. Can we support this in multi and all and merge the namespaces? Maybe we can do it that way and then put finalizers as include finalizers flag or something like that?
> > WDYT?
> 
> Not sure if there is a good implementation of this without #167 , after reasoning will be added I will create a new pr that will include it in all and multi.
> 
> if we add this now it would probably all be deleted/changed in the next pr, I think the current implementation of using `kor finalizers` which also supports all of the filters options is fine for now.

Ok.
Can you just add the command in the Readme and then we can merge it

Patch:
diff --git a/README.md b/README.md
index 595c59d..6b4bc4f 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
 - `crd` - Gets unused CRDs in the cluster(non namespaced resource).
 - `jobs` - Gets unused jobs for the specified namespace or all namespaces.
 - `replicasets` - Gets unused replicaSets for the specified namespace or all namespaces.
+- `finalizers` - Gets unused pending deletion resources for the specified namespace or all namespaces.
 - `exporter` - Export Prometheus metrics.
 
 ### Supported Flags
diff --git a/cmd/kor/finalizers.go b/cmd/kor/finalizers.go
new file mode 100644
index 0000000..bd775e7
--- /dev/null
+++ b/cmd/kor/finalizers.go
@@ -0,0 +1,28 @@
+package kor
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"github.com/yonahd/kor/pkg/kor"
+)
+
+var finalizerCmd = &cobra.Command{
+	Use:     "finalizer",
+	Aliases: []string{"fin", "finalizers"},
+	Short:   "Gets resources waiting for finalizers to delete",
+	Args:    cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+		clientset := kor.GetKubeClient(kubeconfig)
+		dynamicClient := kor.GetDynamicClient(kubeconfig)
+		if response, err := kor.GetUnusedfinalizers(includeExcludeLists, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil {
+			fmt.Println(err)
+		} else {
+			fmt.Println(response)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(finalizerCmd)
+}
diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go
index 69179d3..e95e7e9 100644
--- a/pkg/kor/delete.go
+++ b/pkg/kor/delete.go
@@ -16,6 +16,9 @@ import (
 	policyv1beta1 "k8s.io/api/policy/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/dynamic"
 	"k8s.io/client-go/kubernetes"
 )
 
@@ -71,6 +74,28 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
 	return deleteResourceApiMap
 }
 
+func FlagDynamicResource(dynamicClient dynamic.Interface, namespace string, gvr schema.GroupVersionResource, resourceName string) error {
+	resource, err := dynamicClient.
+		Resource(gvr).
+		Namespace(namespace).
+		Get(context.TODO(), resourceName, metav1.GetOptions{})
+	if err != nil {
+		return err
+	}
+
+	labels := resource.GetLabels()
+	if labels == nil {
+		labels = make(map[string]string)
+	}
+	labels["kor/used"] = "true"
+	resource.SetLabels(labels)
+	_, err = dynamicClient.
+		Resource(gvr).
+		Namespace(namespace).
+		Update(context.TODO(), resource, metav1.UpdateOptions{})
+	return err
+}
+
 func FlagResource(clientset kubernetes.Interface, namespace, resourceType, resourceName string) error {
 	resource, err := getResource(clientset, namespace, resourceType, resourceName)
 	if err != nil {
@@ -165,6 +190,57 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
 
+func DeleteResourceWithFinalizer(diff []string, dynamicClient dynamic.Interface, namespace string, gvr schema.GroupVersionResource, noInteractive bool) ([]string, error) {
+	deletedDiff := []string{}
+
+	for _, resourceName := range diff {
+
+		if !noInteractive {
+			fmt.Printf("Do you want to delete %s %s in namespace %s? (Y/N): ", gvr.Resource, resourceName, namespace)
+			var confirmation string
+			_, err := fmt.Scanf("%s", &confirmation)
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err)
+				continue
+			}
+
+			if strings.ToLower(confirmation) != "y" && strings.ToLower(confirmation) != "yes" {
+				deletedDiff = append(deletedDiff, resourceName)
+
+				fmt.Printf("Do you want flag the resource %s %s in namespace %s as In Use? (Y/N): ", gvr.Resource, resourceName, namespace)
+				var inUse string
+				_, err := fmt.Scanf("%s", &inUse)
+				if err != nil {
+					fmt.Fprintf(os.Stderr, "Failed to read input: %v\n", err)
+					continue
+				}
+
+				if strings.ToLower(inUse) == "y" || strings.ToLower(inUse) == "yes" {
+					if err := FlagDynamicResource(dynamicClient, namespace, gvr, resourceName); err != nil {
+						fmt.Fprintf(os.Stderr, "Failed to flag resource %s %s in namespace %s as In Use: %v\n", gvr.Resource, resourceName, namespace, err)
+					}
+					continue
+				}
+				continue
+			}
+		}
+
+		fmt.Printf("Deleting %s %s in namespace %s\n", gvr.Resource, resourceName, namespace)
+		if _, err := dynamicClient.
+			Resource(gvr).
+			Namespace(namespace).
+			Patch(context.TODO(), resourceName, types.MergePatchType,
+				[]byte(`{"metadata":{"finalizers":null}}`),
+				metav1.PatchOptions{}); err != nil {
+			fmt.Fprintf(os.Stderr, "Failed to delete %s %s in namespace %s: %v\n", gvr.Resource, resourceName, namespace, err)
+			continue
+		}
+		deletedDiff = append(deletedDiff, resourceName+"-DELETED")
+	}
+
+	return deletedDiff, nil
+}
+
 func DeleteResource(diff []string, clientset kubernetes.Interface, namespace, resourceType string, noInteractive bool) ([]string, error) {
 	deletedDiff := []string{}
 
diff --git a/pkg/kor/finalizers.go b/pkg/kor/finalizers.go
new file mode 100644
index 0000000..f80697a
--- /dev/null
+++ b/pkg/kor/finalizers.go
@@ -0,0 +1,128 @@
+package kor
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/dynamic"
+	"k8s.io/client-go/kubernetes"
+	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
+	"k8s.io/utils/strings/slices"
+)
+
+func CheckFinalizers(finalizers []string, deletionTimestamp *metav1.Time) bool {
+	if len(finalizers) > 0 && deletionTimestamp != nil {
+		return true
+	}
+	return false
+}
+
+func retrievePendingDeletionResources(resourceTypes []*metav1.APIResourceList, dynamicClient dynamic.Interface, filterOpts *FilterOptions) (map[string]map[schema.GroupVersionResource][]string, error) {
+	pendingDeletionResources := make(map[string]map[schema.GroupVersionResource][]string) //map[namespace]map[gvr][]resourceNames
+
+	for _, apiResourceList := range resourceTypes {
+		gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion)
+		if err != nil {
+			return pendingDeletionResources, err
+		}
+
+		for _, resourceType := range apiResourceList.APIResources {
+
+			if slices.Contains(resourceType.Verbs, "list") {
+
+				gvr := gv.WithResource(resourceType.Name)
+				resourceList, err := dynamicClient.
+					Resource(gvr).
+					Namespace(metav1.NamespaceAll).
+					List(context.TODO(), metav1.ListOptions{})
+				if err != nil {
+					fmt.Printf("Error listing resources for GVR %s: %v\n", apiResourceList.GroupVersion, err)
+					continue
+				}
+				for _, item := range resourceList.Items {
+					labels := item.GetLabels()
+					if labels["kor/used"] == "true" {
+						continue
+					}
+
+					// Check for excluded labels
+					if excluded, _ := HasExcludedLabel(labels, filterOpts.ExcludeLabels); excluded {
+						continue
+					}
+
+					// Check age criteria
+					if included, _ := HasIncludedAge(item.GetCreationTimestamp(), filterOpts); !included {
+						continue
+					}
+					if CheckFinalizers(item.GetFinalizers(), item.GetDeletionTimestamp()) {
+						if pendingDeletionResources[item.GetNamespace()] == nil {
+							pendingDeletionResources[item.GetNamespace()] = make(map[schema.GroupVersionResource][]string)
+						}
+						pendingDeletionResources[item.GetNamespace()][gvr] = append(pendingDeletionResources[item.GetNamespace()][gvr], item.GetName())
+					}
+				}
+			}
+		}
+	}
+	return pendingDeletionResources, nil
+}
+
+func getResourcesWithFinalizersPendingDeletion(clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *FilterOptions) (map[string]map[schema.GroupVersionResource][]string, error) {
+	// Use the discovery client to fetch API resources
+	resourceTypes, err := clientset.Discovery().ServerPreferredNamespacedResources()
+	if err != nil {
+		fmt.Printf("Error fetching server resources: %v\n", err)
+		os.Exit(1)
+	}
+
+	return retrievePendingDeletionResources(resourceTypes, dynamicClient, filterOpts)
+}
+
+func GetUnusedfinalizers(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOptions, clientset kubernetes.Interface, dynamicClient *dynamic.DynamicClient, outputFormat string, opts Opts) (string, error) {
+	var outputBuffer bytes.Buffer
+	namespaces := SetNamespaceList(includeExcludeLists, clientset)
+	response := make(map[string]map[string][]string)
+	pendingDeletionDiffs, err := getResourcesWithFinalizersPendingDeletion(clientset, dynamicClient, filterOpts)
+
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to process resources waiting for finalizers: %v\n", err)
+	}
+
+	allDiffs := make(map[string][]string)
+
+	for namespace, resourceType := range pendingDeletionDiffs {
+		if slices.Contains(namespaces, namespace) {
+			for gvr, resourceDiff := range resourceType {
+				if opts.DeleteFlag {
+					if resourceDiff, err = DeleteResourceWithFinalizer(resourceDiff, dynamicClient, namespace, gvr, opts.NoInteractive); err != nil {
+						fmt.Fprintf(os.Stderr, "Failed to delete objects waiting for Finalizers %s in namespace %s: %v\n", resourceDiff, namespace, err)
+					}
+				}
+				allDiffs[gvr.Resource] = resourceDiff
+			}
+
+			output := FormatOutputFromMap(namespace, allDiffs, opts)
+			outputBuffer.WriteString(output)
+			outputBuffer.WriteString("\n")
+
+			response[namespace] = allDiffs
+		}
+	}
+
+	jsonResponse, err := json.MarshalIndent(response, "", "  ")
+	if err != nil {
+		return "", err
+	}
+
+	unusedFinalizers, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
+	if err != nil {
+		fmt.Printf("err: %v\n", err)
+	}
+
+	return unusedFinalizers, nil
+}
diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go
index f937fdd..1ff1ee1 100644
--- a/pkg/kor/kor.go
+++ b/pkg/kor/kor.go
@@ -180,6 +180,41 @@ func FormatOutput(namespace string, resources []string, resourceType string, opt
 	return fmt.Sprintf("Unused %s in Namespace: %s\n%s", resourceType, namespace, buf.String())
 }
 
+func FormatOutputFromMap(namespace string, allDiffs map[string][]string, opts Opts) string {
+	i := 0
+	var buf bytes.Buffer
+	table := tablewriter.NewWriter(&buf)
+	table.SetHeader([]string{"#", "Resource Type", "Resource Name"})
+
+	// TODO parse resourceType, diff
+
+	allEmpty := true
+	for resourceType, diff := range allDiffs {
+		if len(diff) == 0 {
+			continue
+		}
+
+		allEmpty = false
+		for _, val := range diff {
+			row := []string{fmt.Sprintf("%d", i+1), resourceType, val}
+			table.Append(row)
+			i += 1
+		}
+	}
+
+	if opts.Verbose && allEmpty {
+		return fmt.Sprintf("No unused resources found in the namespace: %s", namespace)
+	} else if allEmpty {
+		return ""
+	}
+
+	table.Render()
+	if namespace == "" {
+		return fmt.Sprintf("Unused CRDs: \n%s", buf.String())
+	}
+	return fmt.Sprintf("Unused Resources in Namespace: %s\n%s", namespace, buf.String())
+}
+
 func FormatOutputAll(namespace string, allDiffs []ResourceDiff, opts Opts) string {
 	i := 0
 	var buf bytes.Buffer


Test patch:
diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go
index d55fd31..2c4f581 100644
--- a/pkg/kor/create_test_resources.go
+++ b/pkg/kor/create_test_resources.go
@@ -8,7 +8,8 @@ import (
 	networkingv1 "k8s.io/api/networking/v1"
 	policyv1 "k8s.io/api/policy/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
-	"k8s.io/apimachinery/pkg/apis/meta/v1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 )
 
 var testNamespace = "test-namespace"
@@ -261,6 +262,20 @@ func CreateTestConfigmap(namespace, name string) *corev1.ConfigMap {
 	}
 }
 
+func CreateTestUnstuctered(kind, apiVersion, namespace, name string) *unstructured.Unstructured {
+	return &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"kind":       kind,
+			"apiVersion": apiVersion,
+			"metadata": map[string]interface{}{
+				"name":      name,
+				"namespace": namespace,
+			},
+			"spec": map[string]interface{}{},
+		},
+	}
+}
+
 func CreateTestJob(namespace, name string, status *batchv1.JobStatus) *batchv1.Job {
 	return &batchv1.Job{
 		ObjectMeta: v1.ObjectMeta{
@@ -306,4 +321,4 @@ func CreateTestReplicaSet(namespace, name string, specReplicas *int32, status *a
 		},
 		Status: *status,
 	}
-}
+}
\ No newline at end of file
diff --git a/pkg/kor/delete_test.go b/pkg/kor/delete_test.go
index 2d7ed95..4db95e2 100644
--- a/pkg/kor/delete_test.go
+++ b/pkg/kor/delete_test.go
@@ -1,9 +1,15 @@
 package kor
 
 import (
+	"context"
 	"testing"
 
-	"k8s.io/client-go/kubernetes/fake"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	fakedynamic "k8s.io/client-go/dynamic/fake"
+	fake "k8s.io/client-go/kubernetes/fake"
 )
 
 func TestDeleteResource(t *testing.T) {
@@ -37,3 +43,132 @@ func TestDeleteResource(t *testing.T) {
 		})
 	}
 }
+
+func TestDeleteDeleteResourceWithFinalizer(t *testing.T) {
+	scheme := runtime.NewScheme()
+	gvr := schema.GroupVersionResource{Group: "testgroup", Version: "v1", Resource: "TestResource"}
+	testResource := CreateTestUnstuctered(gvr.Resource, gvr.GroupVersion().String(), testNamespace, "test-resource")
+	dynamicClient := fakedynamic.NewSimpleDynamicClient(scheme, testResource)
+
+	_, err := dynamicClient.Resource(gvr).
+		Namespace(testNamespace).
+		Create(context.TODO(), testResource, metav1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating test resource: %v", err)
+	}
+
+	_, err = dynamicClient.
+		Resource(gvr).
+		Namespace(testNamespace).
+		Patch(context.TODO(), "test-resource", types.MergePatchType,
+			[]byte(`{"metadata":{"finalizers":["finalizer1", "finalizer2", "finalizer3"]}}`),
+			metav1.PatchOptions{})
+
+	if err != nil {
+		t.Fatalf("Error patching test resource: %v", err)
+	}
+
+	tests := []struct {
+		name          string
+		diff          []string
+		resourceType  string
+		expectedDiff  []string
+		expectedError bool
+	}{
+		{
+			name:          "Test deletion confirmation",
+			diff:          []string{testResource.GetName()},
+			expectedDiff:  []string{testResource.GetName() + "-DELETED"},
+			expectedError: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			deletedDiff, _ := DeleteResourceWithFinalizer(test.diff, dynamicClient, testNamespace, gvr, true)
+
+			for i, deleted := range deletedDiff {
+				if deleted != test.expectedDiff[i] {
+					t.Errorf("Expected: %s, Got: %s", test.expectedDiff[i], deleted)
+					resource, err := dynamicClient.Resource(gvr).
+						Namespace(testNamespace).
+						Get(context.TODO(), deleted, metav1.GetOptions{})
+					if err != nil {
+						t.Error(err)
+					}
+					if resource.GetFinalizers() != nil {
+						t.Error("Finalizers not patched")
+					}
+				}
+			}
+
+		})
+	}
+}
+
+func TestFlagDynamicResource(t *testing.T) {
+	scheme := runtime.NewScheme()
+	gvr := schema.GroupVersionResource{Group: "testgroup", Version: "v1", Resource: "TestResource"}
+	testResource := CreateTestUnstuctered(gvr.Resource, gvr.GroupVersion().String(), testNamespace, "test-resource")
+	testResourceWithLabel := CreateTestUnstuctered(gvr.Resource, gvr.GroupVersion().String(), testNamespace, "test-resource-with-label")
+	dynamicClient := fakedynamic.NewSimpleDynamicClient(scheme, testResource, testResourceWithLabel)
+	testResourceWithLabel.SetLabels(map[string]string{
+		"test": "true",
+	})
+
+	_, err := dynamicClient.Resource(gvr).
+		Namespace(testNamespace).
+		Create(context.TODO(), testResource, metav1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating test resource: %v", err)
+	}
+	_, err = dynamicClient.Resource(gvr).
+		Namespace(testNamespace).
+		Create(context.TODO(), testResourceWithLabel, metav1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating test resource with finalizers: %v", err)
+	}
+
+	tests := []struct {
+		name          string
+		gvr           schema.GroupVersionResource
+		resourceName  string
+		labels        bool
+		expectedError bool
+	}{
+		{
+			name:          "Test flagging dynamic resource",
+			resourceName:  "test-resource",
+			labels:        false,
+			expectedError: false,
+		},
+		{
+			name:          "Test flagging dynamic resource with labels",
+			resourceName:  "test-resource-with-label",
+			labels:        true,
+			expectedError: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			err := FlagDynamicResource(dynamicClient, testNamespace, gvr, test.resourceName)
+
+			if (err != nil) != test.expectedError {
+				t.Errorf("Expected error: %v, Got: %v", test.expectedError, err)
+			}
+			resource, err := dynamicClient.Resource(gvr).
+				Namespace(testNamespace).
+				Get(context.TODO(), test.resourceName, metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			if resource.GetLabels()["kor/used"] != "true" {
+				t.Errorf("Expected resource flagged as used, Got: %v", resource.GetLabels()["kor/used"])
+			}
+			if test.labels == true && resource.GetLabels()["test"] != "true" {
+				t.Errorf("Resource Lost his labels")
+			}
+		})
+	}
+}
diff --git a/pkg/kor/finalizers_test.go b/pkg/kor/finalizers_test.go
new file mode 100644
index 0000000..7e18f65
--- /dev/null
+++ b/pkg/kor/finalizers_test.go
@@ -0,0 +1,99 @@
+package kor
+
+import (
+	"testing"
+	"time"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	fakedynamic "k8s.io/client-go/dynamic/fake"
+	"k8s.io/utils/strings/slices"
+)
+
+func TestCheckFinalizers(t *testing.T) {
+	tests := []struct {
+		name              string
+		finalizers        []string
+		deletionTimestamp *metav1.Time
+		expectedResult    bool
+	}{
+		{"EmptyFinalizersAndNilDeletionTimestamp", []string{}, nil, false},
+		{"NonEmptyFinalizersAndNilDeletionTimestamp", []string{"finalizer1", "finalizer2"}, nil, false},
+		{"EmptyFinalizersAndDeletionTimestamp", []string{}, &metav1.Time{}, false},
+		{"NonEmptyFinalizersAndDeletionTimestamp", []string{"finalizer1", "finalizer2"}, &metav1.Time{}, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := CheckFinalizers(tt.finalizers, tt.deletionTimestamp)
+			if result != tt.expectedResult {
+				t.Errorf("Expected result %v, but got %v", tt.expectedResult, result)
+			}
+		})
+	}
+}
+
+func TestRetrievePendingDeletionResources(t *testing.T) {
+	scheme := runtime.NewScheme()
+
+	gvr := schema.GroupVersionResource{Group: "testgroup", Version: "v1", Resource: "testresources"}
+	testResource := CreateTestUnstuctered("TestResource", gvr.GroupVersion().String(), testNamespace, "test-resource")
+	testResource.SetFinalizers([]string{"test", "test2"})
+	testResource.SetDeletionTimestamp(&metav1.Time{Time: time.Now()})
+	dynamicClient := fakedynamic.NewSimpleDynamicClient(scheme, testResource)
+
+	apiResourceLists := []*metav1.APIResourceList{
+		{
+			GroupVersion: "testgroup/v1",
+			APIResources: []metav1.APIResource{
+				{
+					Name:         "testresources",
+					Kind:         "TestResource",
+					Verbs:        []string{"list"},
+					Namespaced:   true,
+					Group:        "testgroup",
+					Version:      "v1",
+					SingularName: "testresource",
+				},
+				{
+					Name:         "testresourceswithoutlist",
+					Kind:         "TestResourceWithoutList",
+					Verbs:        []string{"get"},
+					Namespaced:   true,
+					Group:        "testgroup",
+					Version:      "v1",
+					SingularName: "testresourcewithoutlist",
+				},
+			},
+		},
+		{
+			GroupVersion: "bad//api/version",
+			APIResources: []metav1.APIResource{},
+		},
+	}
+
+	tests := []struct {
+		name             string
+		apiResourceLists []*metav1.APIResourceList
+		expectedError    bool
+		expectedResult   []string
+	}{
+		{"resourceInTerminatingState", []*metav1.APIResourceList{apiResourceLists[0]}, false, []string{testResource.GetName()}},
+		{"badGVList", []*metav1.APIResourceList{apiResourceLists[1]}, true, []string{}},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			result, err := retrievePendingDeletionResources(test.apiResourceLists, dynamicClient, &FilterOptions{})
+			if (err != nil) != test.expectedError {
+				t.Errorf("Expected error: %v, Got: %v", test.expectedError, err)
+			}
+			if deletedResources, ok := result[testNamespace][gvr.GroupVersion().WithResource("testresources")]; ok {
+				if !slices.Equal(deletedResources, test.expectedResult) {
+					t.Errorf("Expected result: %v, Got: %v", test.expectedResult, deletedResources)
+				}
+			}
+		})
+	}
+}