Repository: yonahd/kor
Pull request #445 (2025-05-19 15:30:39)

Feat: Get plural resource kinds from K8s
<!-- 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
-->

<!-- A short description of what your PR does and what it solves. -->

## What this PR does / why we need it?

This uses the K8s API to get plural resource kinds instead of relying on hard-coding an `s` after them.

For example, instead of printing `storageclasss`, which is wrong, now we print `storageclasses`.

## PR Checklist

- [ ] This PR adds K8s exceptions (false positives)
- [x] This PR adds new code
- [ ] This PR includes tests for new/existing code
- [ ] This PR adds docs
<!-- - [ ] This PR does something else -->

yonahd: Interesting. Was not familiar with this api
Can we maybe utilize it here?
https://github.com/yonahd/kor/blob/main/pkg%2Fkor%2Fmulti.go#L25
yonahd: @eden881 do you plan on completing this one? 
eden881: @yonahd Should be ready to go :-)
I mocked `ResourceKindList` in the test case since a real Kubernetes client is never actually initialized, but it behaves the same.
eden881: Rebased as requested @yonahd 

Patch:
diff --git a/cmd/kor/root.go b/cmd/kor/root.go
index 7853413..eeb23a2 100644
--- a/cmd/kor/root.go
+++ b/cmd/kor/root.go
@@ -55,9 +55,15 @@ var (
 func init() {
 	initFlags()
 	initViper()
+	initKindsList()
 	addFilterOptionsFlag(rootCmd, filterOptions)
 }
 
+func initKindsList() {
+	clientset := kor.GetKubeClient(kubeconfig)
+	kor.ResourceKindList, _ = kor.GetResourceKinds(clientset)
+}
+
 func initFlags() {
 	rootCmd.PersistentFlags().StringVarP(&kubeconfig, "kubeconfig", "k", "", "Path to kubeconfig file (optional)")
 	rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "Output format (table, json or yaml)")
diff --git a/pkg/kor/formatter.go b/pkg/kor/formatter.go
index dc7e52e..75ef05e 100644
--- a/pkg/kor/formatter.go
+++ b/pkg/kor/formatter.go
@@ -131,7 +131,7 @@ func formatOutputForNamespace(namespace string, resources map[string][]ResourceI
 func formatOutputForResource(resource string, resources map[string][]ResourceInfo, opts common.Opts) string {
 	if len(resources) == 0 {
 		if opts.Verbose {
-			return fmt.Sprintf("No unused %ss found\n", resource)
+			return fmt.Sprintf("No unused %s found\n", ResourceKindList[strings.ToLower(resource)].Plural)
 		}
 		return ""
 	}
@@ -151,7 +151,7 @@ func formatOutputForResource(resource string, resources map[string][]ResourceInf
 		}
 	}
 	table.Render()
-	return fmt.Sprintf("Unused %ss:\n%s\n", resource, buf.String())
+	return fmt.Sprintf("Unused %s:\n%s\n", ResourceKindList[strings.ToLower(resource)].Plural, buf.String())
 }
 
 func appendResources(resources map[string]map[string][]ResourceInfo, resourceType, namespace string, diff []ResourceInfo) {
diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go
index be6968c..2474dfc 100644
--- a/pkg/kor/kor.go
+++ b/pkg/kor/kor.go
@@ -16,6 +16,8 @@ import (
 	"k8s.io/client-go/util/homedir"
 )
 
+var ResourceKindList map[string]ResourceKind
+
 type ExceptionResource struct {
 	Namespace    string
 	ResourceName string
@@ -25,6 +27,10 @@ type IncludeExcludeLists struct {
 	IncludeListStr string
 	ExcludeListStr string
 }
+type ResourceKind struct {
+	Plural     string
+	ShortNames []string
+}
 
 type Config struct {
 	ExceptionClusterRoles    []ExceptionResource `json:"exceptionClusterRoles"`
@@ -205,3 +211,34 @@ func convertNamesToPresenseMap(names []string, _ []string, err error) (map[strin
 
 	return namesMap, nil
 }
+
+func GetResourceKinds(clientset kubernetes.Interface) (map[string]ResourceKind, error) {
+	resourceTypes, err := clientset.Discovery().ServerPreferredResources()
+	if err != nil {
+		return nil, fmt.Errorf("error fetching server resources: %v", err)
+	}
+
+	kinds := make(map[string]ResourceKind)
+
+	for _, list := range resourceTypes {
+		if list == nil {
+			continue // Skip nil lists (can happen if some APIs are unavailable)
+		}
+
+		for _, resource := range list.APIResources {
+			singularName := resource.SingularName
+			if singularName == "" {
+				singularName = resource.Name
+			}
+
+			resourceKind := ResourceKind{
+				Plural:     resource.Name,
+				ShortNames: resource.ShortNames,
+			}
+
+			kinds[singularName] = resourceKind
+		}
+	}
+
+	return kinds, nil
+}
diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go
index bcd3b7b..c33a799 100644
--- a/pkg/kor/multi.go
+++ b/pkg/kor/multi.go
@@ -15,30 +15,52 @@ import (
 	"github.com/yonahd/kor/pkg/filters"
 )
 
+func getCanonicalResourceType(resourceName string) string {
+	resourceName = strings.ToLower(resourceName)
+
+	if _, exists := ResourceKindList[resourceName]; exists {
+		return resourceName
+	}
+
+	for singular, resourceKind := range ResourceKindList {
+		if resourceKind.Plural == resourceName {
+			return singular
+		}
+		for _, shortName := range resourceKind.ShortNames {
+			if shortName == resourceName {
+				return singular
+			}
+		}
+	}
+
+	return resourceName
+}
+
 func retrieveNoNamespaceDiff(clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, resourceList []string, filterOpts *filters.Options) ([]ResourceDiff, []string) {
 	var noNamespaceDiff []ResourceDiff
 	markedForRemoval := make([]bool, len(resourceList))
 	updatedResourceList := resourceList
 
 	for counter, resource := range resourceList {
-		switch resource {
-		case "crd", "crds", "customresourcedefinition", "customresourcedefinitions":
+		canonicalType := getCanonicalResourceType(resource)
+		switch canonicalType {
+		case "customresourcedefinition":
 			crdDiff := getUnusedCrds(apiExtClient, dynamicClient, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, crdDiff)
 			markedForRemoval[counter] = true
-		case "pv", "persistentvolume", "persistentvolumes":
+		case "persistentvolume":
 			pvDiff := getUnusedPvs(clientset, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, pvDiff)
 			markedForRemoval[counter] = true
-		case "clusterrole", "clusterroles":
+		case "clusterrole":
 			clusterRoleDiff := getUnusedClusterRoles(clientset, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, clusterRoleDiff)
 			markedForRemoval[counter] = true
-		case "sc", "storageclass", "storageclasses":
+		case "storageclass":
 			storageClassDiff := getUnusedStorageClasses(clientset, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, storageClassDiff)
 			markedForRemoval[counter] = true
-		case "volumeattachment", "volumeattachments":
+		case "volumeattachment":
 			vattsDiff := getUnusedVolumeAttachments(clientset, filterOpts)
 			noNamespaceDiff = append(noNamespaceDiff, vattsDiff)
 			markedForRemoval[counter] = true
@@ -60,41 +82,42 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re
 	var allDiffs []ResourceDiff
 	for _, resource := range resourceList {
 		var diffResult ResourceDiff
-		switch resource {
-		case "cm", "configmap", "configmaps":
+		canonicalType := getCanonicalResourceType(resource)
+		switch canonicalType {
+		case "configmap":
 			diffResult = getUnusedCMs(clientset, namespace, filterOpts, opts)
-		case "svc", "service", "services":
+		case "service":
 			diffResult = getUnusedSVCs(clientset, namespace, filterOpts, opts)
-		case "secret", "secrets":
+		case "secret":
 			diffResult = getUnusedSecrets(clientset, namespace, filterOpts, opts)
-		case "sa", "serviceaccount", "serviceaccounts":
+		case "serviceaccount":
 			diffResult = getUnusedServiceAccounts(clientset, namespace, filterOpts, opts)
-		case "deploy", "deployment", "deployments":
+		case "deployment":
 			diffResult = getUnusedDeployments(clientset, namespace, filterOpts, opts)
-		case "sts", "statefulset", "statefulsets":
+		case "statefulset":
 			diffResult = getUnusedStatefulSets(clientset, namespace, filterOpts, opts)
-		case "role", "roles":
+		case "role":
 			diffResult = getUnusedRoles(clientset, namespace, filterOpts, opts)
-		case "hpa", "horizontalpodautoscaler", "horizontalpodautoscalers":
+		case "horizontalpodautoscaler":
 			diffResult = getUnusedHpas(clientset, namespace, filterOpts, opts)
-		case "pvc", "persistentvolumeclaim", "persistentvolumeclaims":
+		case "persistentvolumeclaim":
 			diffResult = getUnusedPvcs(clientset, namespace, filterOpts, opts)
-		case "ing", "ingress", "ingresses":
+		case "ingress":
 			diffResult = getUnusedIngresses(clientset, namespace, filterOpts, opts)
-		case "pdb", "poddisruptionbudget", "poddisruptionbudgets":
+		case "poddisruptionbudget":
 			diffResult = getUnusedPdbs(clientset, namespace, filterOpts, opts)
-		case "po", "pod", "pods":
+		case "pod":
 			diffResult = getUnusedPods(clientset, namespace, filterOpts, opts)
-		case "job", "jobs":
+		case "job":
 			diffResult = getUnusedJobs(clientset, namespace, filterOpts, opts)
-		case "rs", "replicaset", "replicasets":
+		case "replicaset":
 			diffResult = getUnusedReplicaSets(clientset, namespace, filterOpts, opts)
-		case "ds", "daemonset", "daemonsets":
+		case "daemonset":
 			diffResult = getUnusedDaemonSets(clientset, namespace, filterOpts, opts)
-		case "netpol", "networkpolicy", "networkpolicies":
-			diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts, opts)
-		case "rolebinding", "rolebindings":
+		case "networkpolicy":
 			diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts, opts)
+		case "rolebinding":
+			diffResult = getUnusedRoleBindings(clientset, namespace, filterOpts, opts)
 		default:
 			fmt.Printf("resource type %q is not supported\n", resource)
 		}


Test patch:
diff --git a/pkg/kor/multi_test.go b/pkg/kor/multi_test.go
index b12a98a..540e219 100644
--- a/pkg/kor/multi_test.go
+++ b/pkg/kor/multi_test.go
@@ -17,6 +17,21 @@ import (
 func createTestMultiResources(t *testing.T) *fake.Clientset {
 	clientset := fake.NewClientset()
 
+	ResourceKindList = map[string]ResourceKind{
+		"configmap": {
+			Plural:     "configmaps",
+			ShortNames: []string{"cm"},
+		},
+		"poddisruptionbudget": {
+			Plural:     "poddisruptionbudgets",
+			ShortNames: []string{"pdb"},
+		},
+		"deployment": {
+			Plural:     "deployments",
+			ShortNames: []string{"deploy"},
+		},
+	}
+
 	_, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
 		ObjectMeta: v1.ObjectMeta{Name: testNamespace},
 	}, v1.CreateOptions{})