Repository: yonahd/kor (go)

Issue:
Feature: Discover unused volumeattachments
**Is your feature request related to a problem? Please describe.**  
I need to find unused `VolumeAttachments` that were not deleted properly, where all their referenced resources (`CSIDriver`, `PersistentVolume`, `Node`) no longer exist.  

**Describe the solution you'd like**  
Identify and list `VolumeAttachments` whose referenced resources (`CSIDriver`, `PersistentVolume`, `Node`) no longer exist, and provide the ability to delete them.  

**Describe alternatives you've considered**  
Instead of checking for missing `CSIDriver`, `PersistentVolume`, or `Node`, an alternative approach could be verifying if any Pods are using them or checking if they are still attached. However, this does not fit my use case.




Pull request description:
feat: discover unused volumeattachments
<!-- Please read [CONTRIBUTING.md](CONTRIBUTING.md) for additional information on contributing to this repository! -->

<!--
  !!!! README !!!! Please fill this out.

  Please follow conventional commit naming conventions:

  https://www.conventionalcommits.org/en/v1.0.0/#summary
-->

## What this PR does / why we need it?
We find unused VolumeAttachments by checking if their referenced CSIDriver, PersistentVolume (PV), or Node no longer exists.  

## PR Checklist

- [ ] This PR adds K8s exceptions (false positives)
- [x] This PR adds new code
- [x] This PR includes tests for new/existing code
- [x] This PR adds docs
<!-- - [ ] This PR does something else -->

## GitHub Issue

Closes #416 

## Notes for your reviewers  




Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 285d958..3c0c8b4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,8 +41,9 @@ As adding new orphaned resources capabilities requires the addition or modificat
 ├── cmd/kor
 │   └── <resource>s.go
 ├── pkg/kor
-│   ├── <resource>s
-│   │   └── <resource>s.json
+│   ├── exceptions
+│   │   └── <resource>s
+│   │       └── <resource>s.json
 │   ├── all.go
 │   ├── create_test_resources.go
 │   ├── delete.go
diff --git a/README.md b/README.md
index 38f27ba..7de900b 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
 - StorageClasses
 - NetworkPolicies
 - RoleBindings
+- VolumeAttachments
 
 ![Kor Screenshot](/images/show_reason_screenshot.png)
 
@@ -131,6 +132,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
 - `job` - Gets unused jobs for the specified namespace or all namespaces.
 - `replicaset` - Gets unused replicaSets for the specified namespace or all namespaces.
 - `daemonset`- Gets unused DaemonSets for the specified namespace or all namespaces.
+- `volumeattachment` - Gets unused VolumeAttachments in the cluster (non-namespaced resource).
 - `finalizer` - Gets unused pending deletion resources for the specified namespace or all namespaces.
 - `networkpolicy` - Gets unused NetworkPolicies for the specified namespace or all namespaces.
 - `exporter` - Export Prometheus metrics.
@@ -194,6 +196,7 @@ kor [subcommand] --help
 | DaemonSets      | DaemonSets not scheduled on any nodes                                                                                                                                                                                             |
 | StorageClasses  | StorageClasses not used by any PVs / PVCs                                                                                                                                                                                         |
 | NetworkPolicies | NetworkPolicies with no Pods selected by podSelector or Ingress / Egress rules                                                                                                                                                    |
+| VolumeAttachments | VolumeAttachments referencing a non-existent Node, PV, or CSIDriver |
 
 ### Deleting Unused resources
 
diff --git a/charts/kor/Chart.yaml b/charts/kor/Chart.yaml
index 5468289..96a6c10 100644
--- a/charts/kor/Chart.yaml
+++ b/charts/kor/Chart.yaml
@@ -2,7 +2,7 @@ apiVersion: v2
 name: kor
 description: A Kubernetes Helm Chart to discover orphaned resources using kor
 type: application
-version: 0.1.19
+version: 0.1.20
 appVersion: "0.6.0"
 maintainers:
   - name: "yonahd"
diff --git a/charts/kor/README.md b/charts/kor/README.md
index 7ffd78f..2b4fac3 100644
--- a/charts/kor/README.md
+++ b/charts/kor/README.md
@@ -1,6 +1,6 @@
 # kor
 
-![Version: 0.1.19](https://img.shields.io/badge/Version-0.1.19-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.6.0](https://img.shields.io/badge/AppVersion-0.6.0-informational?style=flat-square)
+![Version: 0.1.20](https://img.shields.io/badge/Version-0.1.20-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.6.0](https://img.shields.io/badge/AppVersion-0.6.0-informational?style=flat-square)
 
 A Kubernetes Helm Chart to discover orphaned resources using kor
 
diff --git a/charts/kor/templates/role.yaml b/charts/kor/templates/role.yaml
index a1d73f1..9035cc2 100644
--- a/charts/kor/templates/role.yaml
+++ b/charts/kor/templates/role.yaml
@@ -65,6 +65,7 @@ rules:
       - persistentvolumes
       - customresourcedefinitions
       - storageclasses
+      - volumeattachments
     verbs:
       - get
       - list
diff --git a/cmd/kor/volumeattachments.go b/cmd/kor/volumeattachments.go
new file mode 100644
index 0000000..899e1e7
--- /dev/null
+++ b/cmd/kor/volumeattachments.go
@@ -0,0 +1,31 @@
+package kor
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/yonahd/kor/pkg/kor"
+	"github.com/yonahd/kor/pkg/utils"
+)
+
+var volumeAttachmentCmd = &cobra.Command{
+	Use:     "volumeattachment",
+	Aliases: []string{"volumeattachments"},
+	Short:   "Gets unused volumeattachments",
+	Args:    cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+		clientset := kor.GetKubeClient(kubeconfig)
+
+		if response, err := kor.GetUnusedVolumeAttachments(filterOptions, clientset, outputFormat, opts); err != nil {
+			fmt.Println(err)
+		} else {
+			utils.PrintLogo(outputFormat)
+			fmt.Println(response)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(volumeAttachmentCmd)
+}
diff --git a/pkg/kor/all.go b/pkg/kor/all.go
index 31c26a0..3fab618 100644
--- a/pkg/kor/all.go
+++ b/pkg/kor/all.go
@@ -253,6 +253,17 @@ func getUnusedStorageClasses(clientset kubernetes.Interface, filterOpts *filters
 	}
 	return allScDiff
 }
+func getUnusedVolumeAttachments(clientset kubernetes.Interface, filterOpts *filters.Options) ResourceDiff {
+	vattsDiff, err := processVolumeAttachments(clientset, filterOpts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "VolumeAttachments", err)
+	}
+	allVattsDiff := ResourceDiff{
+		"VolumeAttachment",
+		vattsDiff,
+	}
+	return allVattsDiff
+}
 
 func getUnusedNetworkPolicies(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff {
 	netpolDiff, err := processNamespaceNetworkPolicies(clientset, namespace, filterOpts)
@@ -352,11 +363,14 @@ func GetUnusedAllNonNamespaced(filterOpts *filters.Options, clientset kubernetes
 		resources[""]["Pv"] = getUnusedPvs(clientset, filterOpts).diff
 		resources[""]["ClusterRole"] = getUnusedClusterRoles(clientset, filterOpts).diff
 		resources[""]["StorageClass"] = getUnusedStorageClasses(clientset, filterOpts).diff
+		resources[""]["VolumeAttachments"] = getUnusedVolumeAttachments(clientset, filterOpts).diff
 	case "resource":
 		appendResources(resources, "Crd", "", getUnusedCrds(apiExtClient, dynamicClient, filterOpts).diff)
 		appendResources(resources, "Pv", "", getUnusedPvs(clientset, filterOpts).diff)
 		appendResources(resources, "ClusterRole", "", getUnusedClusterRoles(clientset, filterOpts).diff)
 		appendResources(resources, "StorageClass", "", getUnusedStorageClasses(clientset, filterOpts).diff)
+		appendResources(resources, "VolumeAttachments", "", getUnusedVolumeAttachments(clientset, filterOpts).diff)
+
 	}
 
 	var outputBuffer bytes.Buffer
diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go
index 7efcec3..015cde8 100644
--- a/pkg/kor/delete.go
+++ b/pkg/kor/delete.go
@@ -84,6 +84,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
 		"RoleBinding": func(clientset kubernetes.Interface, namespace, name string) error {
 			return clientset.RbacV1().RoleBindings(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
+		"VolumeAttachment": func(clientset kubernetes.Interface, namespace, name string) error {
+			return clientset.StorageV1().VolumeAttachments().Delete(context.TODO(), name, metav1.DeleteOptions{})
+		},
 	}
 
 	return deleteResourceApiMap
@@ -175,6 +178,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
 		return clientset.NetworkingV1().NetworkPolicies(namespace).Update(context.TODO(), resource.(*networkingv1.NetworkPolicy), metav1.UpdateOptions{})
 	case "RoleBinding":
 		return clientset.RbacV1().RoleBindings(namespace).Update(context.TODO(), resource.(*rbacv1.RoleBinding), metav1.UpdateOptions{})
+	case "VolumeAttachment":
+		return clientset.StorageV1().VolumeAttachments().Update(context.TODO(), resource.(*storagev1.VolumeAttachment), metav1.UpdateOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
@@ -221,6 +226,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
 		return clientset.NetworkingV1().NetworkPolicies(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "RoleBinding":
 		return clientset.RbacV1().RoleBindings(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
+	case "volumeAttachment":
+		return clientset.StorageV1().VolumeAttachments().Get(context.TODO(), resourceName, metav1.GetOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go
index ef2dfd9..1849e43 100644
--- a/pkg/kor/multi.go
+++ b/pkg/kor/multi.go
@@ -38,6 +38,10 @@ func retrieveNoNamespaceDiff(clientset kubernetes.Interface, apiExtClient apiext
 			storageClassDiff := getUnusedStorageClasses(clientset, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, storageClassDiff)
 			markedForRemoval[counter] = true
+		case "volumeattachment", "volumeattachments":
+			vattsDiff := getUnusedVolumeAttachments(clientset, filterOpts)
+			noNamespaceDiff = append(noNamespaceDiff, vattsDiff)
+			markedForRemoval[counter] = true
 		}
 	}
 
diff --git a/pkg/kor/volumeattachments.go b/pkg/kor/volumeattachments.go
new file mode 100644
index 0000000..9048b46
--- /dev/null
+++ b/pkg/kor/volumeattachments.go
@@ -0,0 +1,116 @@
+package kor
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
+
+	"github.com/yonahd/kor/pkg/common"
+	"github.com/yonahd/kor/pkg/filters"
+)
+
+func processVolumeAttachments(clientset kubernetes.Interface, filterOpts *filters.Options) ([]ResourceInfo, error) {
+	vaList, err := clientset.StorageV1().VolumeAttachments().List(context.TODO(), metav1.ListOptions{
+		LabelSelector: filterOpts.IncludeLabels,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var unusedVAtts []ResourceInfo
+
+	for _, va := range vaList.Items {
+		if pass, _ := filter.SetObject(&va).Run(filterOpts); pass {
+			continue
+		}
+
+		if va.Labels["kor/used"] == "false" {
+			reason := "Marked with unused label"
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+
+		pvName := va.Spec.Source.PersistentVolumeName
+		if pvName == nil || *pvName == "" {
+			reason := "No PersistentVolume specified in VolumeAttachment"
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+		if _, err := clientset.CoreV1().PersistentVolumes().Get(context.TODO(), *pvName, metav1.GetOptions{}); err != nil {
+			reason := fmt.Sprintf("PersistentVolume %s does not exist", *pvName)
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+
+		nodeName := va.Spec.NodeName
+		if nodeName == "" {
+			reason := "No node specified in VolumeAttachment"
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+		if _, err := clientset.CoreV1().Nodes().Get(context.TODO(), nodeName, metav1.GetOptions{}); err != nil {
+			reason := fmt.Sprintf("Node %s does not exist", nodeName)
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+
+		attacher := va.Spec.Attacher
+		if attacher == "" {
+			reason := "No attacher specified in VolumeAttachment"
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+		if _, err := clientset.StorageV1().CSIDrivers().Get(context.TODO(), attacher, metav1.GetOptions{}); err != nil {
+			reason := fmt.Sprintf("CSIDriver %s does not exist", attacher)
+			unusedVAtts = append(unusedVAtts, ResourceInfo{Name: va.Name, Reason: reason})
+			continue
+		}
+
+	}
+
+	return unusedVAtts, nil
+}
+
+func GetUnusedVolumeAttachments(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) {
+	resources := make(map[string]map[string][]ResourceInfo)
+	diff, err := processVolumeAttachments(clientset, filterOpts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to process volume attachments: %v\n", err)
+	}
+	if opts.DeleteFlag {
+		if diff, err = DeleteResource(diff, clientset, "", "VolumeAttachment", opts.NoInteractive); err != nil {
+			fmt.Fprintf(os.Stderr, "Failed to delete VolumeAttachments: %v\n", err)
+		}
+	}
+	switch opts.GroupBy {
+	case "namespace":
+		resources[""] = make(map[string][]ResourceInfo)
+		resources[""]["VolumeAttachment"] = diff
+	case "resource":
+		appendResources(resources, "VolumeAttachment", "", diff)
+	}
+
+	var outputBuffer bytes.Buffer
+	var jsonResponse []byte
+	switch outputFormat {
+	case "table":
+		outputBuffer = FormatOutput(resources, opts)
+	case "json", "yaml":
+		if jsonResponse, err = json.MarshalIndent(resources, "", "  "); err != nil {
+			return "", err
+		}
+	}
+
+	unusedVAtts, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
+	if err != nil {
+		fmt.Printf("err: %v\n", err)
+	}
+
+	return unusedVAtts, nil
+}


Test patch:
diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go
index 4e4c493..3bf2bb2 100644
--- a/pkg/kor/create_test_resources.go
+++ b/pkg/kor/create_test_resources.go
@@ -451,3 +451,26 @@ func CreateTestNetworkPolicy(name, namespace string, labels map[string]string, p
 		},
 	}
 }
+
+func CreateTestVolumeAttachment(name, attacher, nodeName, pvName string) *storagev1.VolumeAttachment {
+	return &storagev1.VolumeAttachment{
+		ObjectMeta: v1.ObjectMeta{Name: name},
+		Spec: storagev1.VolumeAttachmentSpec{
+			Attacher: attacher,
+			NodeName: nodeName,
+			Source:   storagev1.VolumeAttachmentSource{PersistentVolumeName: &pvName},
+		},
+	}
+}
+
+func CreateTestNode(name string) *corev1.Node {
+	return &corev1.Node{
+		ObjectMeta: v1.ObjectMeta{Name: name},
+	}
+}
+
+func CreateTestCSIDriver(name string) *storagev1.CSIDriver {
+	return &storagev1.CSIDriver{
+		ObjectMeta: v1.ObjectMeta{Name: name},
+	}
+}
diff --git a/pkg/kor/volumeattachments_test.go b/pkg/kor/volumeattachments_test.go
new file mode 100644
index 0000000..5b744c2
--- /dev/null
+++ b/pkg/kor/volumeattachments_test.go
@@ -0,0 +1,102 @@
+package kor
+
+import (
+	"context"
+	"encoding/json"
+	"reflect"
+	"sort"
+	"testing"
+
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes/fake"
+
+	"github.com/yonahd/kor/pkg/common"
+	"github.com/yonahd/kor/pkg/filters"
+)
+
+func createTestVolumeAttachments(t *testing.T) *fake.Clientset {
+	clientset := fake.NewSimpleClientset()
+
+	// Create a valid node
+	_, err := clientset.CoreV1().Nodes().Create(context.TODO(), CreateTestNode("node-1"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating node: %v", err)
+	}
+
+	// Create a valid PV
+	_, err = clientset.CoreV1().PersistentVolumes().Create(context.TODO(), CreateTestPv("pv-1", "", map[string]string{}, ""), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating PV: %v", err)
+	}
+
+	// Create a valid CSIDriver
+	_, err = clientset.StorageV1().CSIDrivers().Create(context.TODO(), CreateTestCSIDriver("csi-driver-1"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating CSIDriver: %v", err)
+	}
+
+	// Create VolumeAttachments
+	_, err = clientset.StorageV1().VolumeAttachments().Create(context.TODO(), CreateTestVolumeAttachment("va-1", "csi-driver-1", "node-1", "pv-1"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating VolumeAttachment %s: %v", "va-1", err)
+	}
+
+	_, err = clientset.StorageV1().VolumeAttachments().Create(context.TODO(), CreateTestVolumeAttachment("va-2", "csi-driver-1", "node-1", "pv-unknown"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating VolumeAttachment %s: %v", "va-2", err)
+	}
+
+	_, err = clientset.StorageV1().VolumeAttachments().Create(context.TODO(), CreateTestVolumeAttachment("va-3", "csi-driver-1", "node-unknown", "pv-1"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating VolumeAttachment %s: %v", "va-3", err)
+	}
+
+	_, err = clientset.StorageV1().VolumeAttachments().Create(context.TODO(), CreateTestVolumeAttachment("va-4", "csi-driver-unknown", "node-1", "pv-1"), v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating VolumeAttachment %s: %v", "va-4", err)
+	}
+
+	return clientset
+}
+
+func TestGetUnusedVolumeAttachments(t *testing.T) {
+	clientset := createTestVolumeAttachments(t)
+
+	opts := common.Opts{
+		WebhookURL:    "",
+		Channel:       "",
+		Token:         "",
+		DeleteFlag:    false,
+		NoInteractive: true,
+		GroupBy:       "namespace",
+	}
+
+	output, err := GetUnusedVolumeAttachments(&filters.Options{}, clientset, "json", opts)
+	if err != nil {
+		t.Fatalf("Error calling GetUnusedVolumeAttachments: %v", err)
+	}
+
+	// Expected unused resources:
+	// - va-2: No PV
+	// - va-3: No Node
+	// - va-4: No Driver
+	expectedUnused := []string{"va-2", "va-3", "va-4"}
+	expectedOutput := map[string]map[string][]string{
+		"": {
+			"VolumeAttachment": expectedUnused,
+		},
+	}
+
+	var actualOutput map[string]map[string][]string
+	if err := json.Unmarshal([]byte(output), &actualOutput); err != nil {
+		t.Fatalf("Error unmarshaling actual output: %v", err)
+	}
+
+	// Sort before comparing since order is not guaranteed
+	sort.Strings(expectedOutput[""]["VolumeAttachment"])
+	sort.Strings(actualOutput[""]["VolumeAttachment"])
+
+	if !reflect.DeepEqual(expectedOutput, actualOutput) {
+		t.Errorf("Expected output %+v, but got %+v", expectedOutput, actualOutput)
+	}
+}