Repository: yonahd/kor
Pull request #176 (2023-12-08 15:20:18)

Fix exporterInterval variable.
exporterInterval variable should be added to values.yaml without quotes and should be added to env with quotes.

Yuni-sa: Nice catch!
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/yonahd/kor/pull/176?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`6ee6450`)](https://app.codecov.io/gh/yonahd/kor/commit/6ee64508c09165447356afbd69be919a67bb65ea?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) 45.96% compared to head [(`c9ca848`)](https://app.codecov.io/gh/yonahd/kor/pull/176?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) 45.96%.


:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #176   +/-   ##
=======================================
  Coverage   45.96%   45.96%           
=======================================
  Files          22       22           
  Lines        2080     2080           
=======================================
  Hits          956      956           
  Misses        962      962           
  Partials      162      162           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/yonahd/kor/pull/176?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen).


Patch:
diff --git a/README.md b/README.md
index 6344e2c..4f3e8ff 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
 - PDBs
 - CRDs
 - PVs
+- Pods
 - Jobs
 
 ![Kor Screenshot](/images/screenshot.png)
@@ -85,6 +86,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
 - `statefulsets` - Gets unused StatefulSets for the specified namespace or all namespaces.
 - `role` - Gets unused Roles for the specified namespace or all namespaces.
 - `hpa` - Gets unused HPAs for the specified namespace or all namespaces.
+- `pods` - Gets unused Pods for the specified namespace or all namespaces.
 - `pvc` - Gets unused PVCs for the specified namespace or all namespaces.
 - `pv` - Gets unused PVs in the cluster(non namespaced resource).
 - `ingress` - Gets unused Ingresses for the specified namespace or all namespaces.
diff --git a/charts/kor/templates/deployment.yaml b/charts/kor/templates/deployment.yaml
index 3abf505..8e6ab98 100644
--- a/charts/kor/templates/deployment.yaml
+++ b/charts/kor/templates/deployment.yaml
@@ -40,7 +40,7 @@ spec:
           {{- if .Values.prometheusExporter.exporterInterval }}
           env:
           - name: EXPORTER_INTERVAL
-            value: {{ .Values.prometheusExporter.exporterInterval }}
+            value: {{ .Values.prometheusExporter.exporterInterval | quote}}
           {{- end}}
       {{- with .Values.prometheusExporter.deployment.nodeSelector }}
       nodeSelector:
diff --git a/cmd/kor/pods.go b/cmd/kor/pods.go
new file mode 100644
index 0000000..94755ee
--- /dev/null
+++ b/cmd/kor/pods.go
@@ -0,0 +1,29 @@
+package kor
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	"github.com/yonahd/kor/pkg/kor"
+	"github.com/yonahd/kor/pkg/utils"
+)
+
+var podCmd = &cobra.Command{
+	Use:     "pod",
+	Aliases: []string{"po", "pods"},
+	Short:   "Gets unused pods",
+	Args:    cobra.ExactArgs(0),
+	Run: func(cmd *cobra.Command, args []string) {
+		clientset := kor.GetKubeClient(kubeconfig)
+		if response, err := kor.GetUnusedPods(includeExcludeLists, filterOptions, clientset, outputFormat, opts); err != nil {
+			fmt.Println(err)
+		} else {
+			utils.PrintLogo(outputFormat)
+			fmt.Println(response)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(podCmd)
+}
diff --git a/pkg/kor/all.go b/pkg/kor/all.go
index 73c64e9..a70172a 100644
--- a/pkg/kor/all.go
+++ b/pkg/kor/all.go
@@ -138,6 +138,15 @@ func getUnusedPvs(clientset kubernetes.Interface, filterOpts *FilterOptions) Res
 	return allPvDiff
 }
 
+func getUnusedPods(clientset kubernetes.Interface, namespace string, filterOpts *FilterOptions) ResourceDiff {
+	podDiff, err := ProcessNamespacePods(clientset, namespace, filterOpts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "pods", namespace, err)
+	}
+	namespacePodDiff := ResourceDiff{"Pod", podDiff}
+	return namespacePodDiff
+}
+
 func getUnusedJobs(clientset kubernetes.Interface, namespace string, filterOpts *FilterOptions) ResourceDiff {
 	jobDiff, err := ProcessNamespaceJobs(clientset, namespace, filterOpts)
 	if err != nil {
diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go
index 8d4e489..1aa3498 100644
--- a/pkg/kor/delete.go
+++ b/pkg/kor/delete.go
@@ -3,11 +3,12 @@ package kor
 import (
 	"context"
 	"fmt"
-	batchv1 "k8s.io/api/batch/v1"
 	"os"
 	"reflect"
 	"strings"
 
+	batchv1 "k8s.io/api/batch/v1"
+
 	appsv1 "k8s.io/api/apps/v1"
 	autoscalingv1 "k8s.io/api/autoscaling/v1"
 	corev1 "k8s.io/api/core/v1"
@@ -56,6 +57,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
 		"PV": func(clientset kubernetes.Interface, namespace, name string) error {
 			return clientset.CoreV1().PersistentVolumes().Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
+		"Pod": func(clientset kubernetes.Interface, namespace, name string) error {
+			return clientset.CoreV1().Pods(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
+		},
 		"Job": func(clientset kubernetes.Interface, namespace, name string) error {
 			return clientset.BatchV1().Jobs(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
@@ -112,6 +116,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
 		return clientset.CoreV1().ServiceAccounts(namespace).Update(context.TODO(), resource.(*corev1.ServiceAccount), metav1.UpdateOptions{})
 	case "PV":
 		return clientset.CoreV1().PersistentVolumes().Update(context.TODO(), resource.(*corev1.PersistentVolume), metav1.UpdateOptions{})
+	case "Pod":
+		return clientset.CoreV1().Pods(namespace).Update(context.TODO(), resource.(*corev1.Pod), metav1.UpdateOptions{})
 	case "Job":
 		return clientset.BatchV1().Jobs(namespace).Update(context.TODO(), resource.(*batchv1.Job), metav1.UpdateOptions{})
 	}
@@ -144,6 +150,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
 		return clientset.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "PV":
 		return clientset.CoreV1().PersistentVolumes().Get(context.TODO(), resourceName, metav1.GetOptions{})
+	case "Pod":
+		return clientset.CoreV1().Pods(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "Job":
 		return clientset.BatchV1().Jobs(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	}
diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go
index 40419d0..2a17891 100644
--- a/pkg/kor/multi.go
+++ b/pkg/kor/multi.go
@@ -68,6 +68,8 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re
 			diffResult = getUnusedIngresses(clientset, namespace, filterOpts)
 		case "pdb", "poddisruptionbudget", "poddisruptionbudgets":
 			diffResult = getUnusedPdbs(clientset, namespace, filterOpts)
+		case "po", "pod", "pods":
+			diffResult = getUnusedPods(clientset, namespace, filterOpts)
 		case "job", "jobs":
 			diffResult = getUnusedJobs(clientset, namespace, filterOpts)
 		default:
diff --git a/pkg/kor/pods.go b/pkg/kor/pods.go
new file mode 100644
index 0000000..aec27bf
--- /dev/null
+++ b/pkg/kor/pods.go
@@ -0,0 +1,87 @@
+package kor
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+)
+
+func ProcessNamespacePods(clientset kubernetes.Interface, namespace string, filterOpts *FilterOptions) ([]string, error) {
+	podsList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	var evictedPods []string
+
+	for _, pod := range podsList.Items {
+		if pod.Labels["kor/used"] == "true" {
+			continue
+		}
+
+		// checks if the resource has any labels that match the excluded selector specified in opts.ExcludeLabels.
+		// If it does, the resource is skipped.
+		if excluded, _ := HasExcludedLabel(pod.Labels, filterOpts.ExcludeLabels); excluded {
+			continue
+		}
+		// checks if the resource's age (measured from its last modified time) matches the included criteria
+		// specified by the filter options.
+		if included, _ := HasIncludedAge(pod.CreationTimestamp, filterOpts); !included {
+			continue
+		}
+
+		if pod.Status.Phase == corev1.PodFailed && pod.Status.Reason == "Evicted" {
+			evictedPods = append(evictedPods, pod.Name)
+		}
+
+	}
+
+	return evictedPods, nil
+}
+
+func GetUnusedPods(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOptions, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) {
+	var outputBuffer bytes.Buffer
+	namespaces := SetNamespaceList(includeExcludeLists, clientset)
+	response := make(map[string]map[string][]string)
+
+	for _, namespace := range namespaces {
+		diff, err := ProcessNamespacePods(clientset, namespace, filterOpts)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err)
+			continue
+		}
+
+		if opts.DeleteFlag {
+			if diff, err = DeleteResource(diff, clientset, namespace, "Pod", opts.NoInteractive); err != nil {
+				fmt.Fprintf(os.Stderr, "Failed to delete Pod %s in namespace %s: %v\n", diff, namespace, err)
+			}
+		}
+		output := FormatOutput(namespace, diff, "Pods", opts)
+		if output != "" {
+			outputBuffer.WriteString(output)
+			outputBuffer.WriteString("\n")
+
+			resourceMap := make(map[string][]string)
+			resourceMap["Pods"] = diff
+			response[namespace] = resourceMap
+		}
+	}
+
+	jsonResponse, err := json.MarshalIndent(response, "", "  ")
+	if err != nil {
+		return "", err
+	}
+
+	unusedPods, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
+	if err != nil {
+		fmt.Printf("err: %v\n", err)
+	}
+
+	return unusedPods, nil
+}


Test patch:
diff --git a/pkg/kor/pods_test.go b/pkg/kor/pods_test.go
new file mode 100644
index 0000000..0fc0cb7
--- /dev/null
+++ b/pkg/kor/pods_test.go
@@ -0,0 +1,146 @@
+package kor
+
+import (
+	"context"
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	fake "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/kubernetes/scheme"
+)
+
+func createTestPods(t *testing.T) *fake.Clientset {
+	clientset := fake.NewSimpleClientset()
+
+	_, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
+		ObjectMeta: v1.ObjectMeta{Name: testNamespace},
+	}, v1.CreateOptions{})
+
+	if err != nil {
+		t.Fatalf("Error creating namespace %s: %v", testNamespace, err)
+	}
+
+	pod1 := CreateTestPod(testNamespace, "pod-1", "", nil)
+	pod1.Status = corev1.PodStatus{
+		Phase:   corev1.PodRunning,
+		Reason:  "",
+		Message: "",
+	}
+	pod2 := CreateTestPod(testNamespace, "pod-2", "", nil)
+	pod2.Status = corev1.PodStatus{
+		Phase:   corev1.PodFailed,
+		Reason:  "Evicted",
+		Message: "",
+	}
+
+	pod3 := CreateTestPod(testNamespace, "pod-3", "", nil)
+	pod3.Status = corev1.PodStatus{
+		Phase:   corev1.PodFailed,
+		Reason:  "CrashLoopBackOff",
+		Message: "",
+	}
+
+	pod4 := CreateTestPod(testNamespace, "pod-4", "", nil)
+	pod4.Status = corev1.PodStatus{
+		Phase:   corev1.PodSucceeded,
+		Reason:  "",
+		Message: "",
+	}
+
+	pod5 := CreateTestPod(testNamespace, "pod-5", "", nil)
+	pod5.Labels = map[string]string{"kor/used": "true"}
+	pod5.Status = corev1.PodStatus{
+		Phase:   corev1.PodFailed,
+		Reason:  "Evicted",
+		Message: "",
+	}
+
+	pod6 := CreateTestPod(testNamespace, "pod-6", "", nil)
+	pod6.Labels = map[string]string{"kor/used": "false"}
+	pod6.Status = corev1.PodStatus{
+		Phase:   corev1.PodFailed,
+		Reason:  "Evicted",
+		Message: "",
+	}
+
+	pods := []*corev1.Pod{pod1, pod2, pod3, pod4, pod5, pod6}
+
+	// Add test pods to the clientset
+	for _, pod := range pods {
+		_, err = clientset.CoreV1().Pods(pod.Namespace).Create(context.TODO(), pod, v1.CreateOptions{})
+		if err != nil {
+			t.Fatalf("Error creating fake pod: %v", err)
+		}
+	}
+
+	return clientset
+}
+
+func TestProcessNamespacePods(t *testing.T) {
+	clientset := createTestPods(t)
+	evictedPods, err := ProcessNamespacePods(clientset, testNamespace, &FilterOptions{})
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+
+	expectedEvictedPods := []string{"pod-2", "pod-6"}
+
+	if len(evictedPods) != len(expectedEvictedPods) {
+		t.Errorf("Expected %d evicted pods, got %d", len(expectedEvictedPods), len(evictedPods))
+	}
+
+	for i, pod := range evictedPods {
+		if pod != expectedEvictedPods[i] {
+			t.Errorf("Expected evicted pod %s, got %s", expectedEvictedPods[i], pod)
+		}
+	}
+}
+
+func TestGetUnusedPodsStructured(t *testing.T) {
+	clientset := createTestPods(t)
+
+	includeExcludeLists := IncludeExcludeLists{
+		IncludeListStr: "",
+		ExcludeListStr: "",
+	}
+
+	opts := Opts{
+		WebhookURL:    "",
+		Channel:       "",
+		Token:         "",
+		DeleteFlag:    false,
+		NoInteractive: true,
+	}
+
+	output, err := GetUnusedPods(includeExcludeLists, &FilterOptions{}, clientset, "json", opts)
+	if err != nil {
+		t.Fatalf("Error calling GetUnusedPodsStructured: %v", err)
+	}
+
+	expectedOutput := map[string]map[string][]string{
+		testNamespace: {
+			"Pods": {"pod-2", "pod-6"},
+		},
+	}
+
+	var actualOutput map[string]map[string][]string
+	if err := json.Unmarshal([]byte(output), &actualOutput); err != nil {
+		t.Fatalf("Error unmarshaling actual output: %v", err)
+	}
+
+	if !reflect.DeepEqual(expectedOutput, actualOutput) {
+		t.Errorf("Expected output does not match actual output")
+		t.Errorf("Expected: %v", expectedOutput)
+		t.Errorf("Actual: %v", actualOutput)
+	}
+}
+
+func init() {
+	scheme.Scheme = runtime.NewScheme()
+	_ = corev1.AddToScheme(scheme.Scheme)
+}