Repository: yonahd/kor
Pull request #175 (2023-12-08 15:14:46)

Fix command in templates.
Command in templates needs to be fixed due to error:
`Error: failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directory: `


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/cronjob.yaml b/charts/kor/templates/cronjob.yaml
index 4f0454b..d503ebf 100644
--- a/charts/kor/templates/cronjob.yaml
+++ b/charts/kor/templates/cronjob.yaml
@@ -17,9 +17,13 @@ spec:
           containers:
             - name: {{ .Release.Name }}-container
               image: {{ .Values.cronJob.image.repository }}:{{ .Values.cronJob.image.tag }}
-              command: ["/bin/sh", "-c"]
+              command:
+                {{- toYaml .Values.cronJob.command | nindent 16 }}
+              args:
+                {{- toYaml .Values.cronJob.args | nindent 16 }}
               {{- if .Values.cronJob.slackWebhookUrl }}
-              args: ["{{ .Values.cronJob.command }} --slack-webhook-url $SLACK_WEBHOOK_URL"]
+                - '--slack-webhook-url'
+                - "${SLACK_WEBHOOK_URL}"
               env:
                 - name: SLACK_WEBHOOK_URL
                   valueFrom:
@@ -27,15 +31,16 @@ spec:
                       name: {{ .Release.Name }}-slack-webhook-url-secret
                       key: slack-webhook-url
               {{- else if and .Values.cronJob.slackChannel .Values.cronJob.slackAuthToken }}
-              args: ["{{ .Values.cronJob.command }} --slack-channel {{ .Values.cronJob.slackChannel }} --slack-auth-token $SLACK_AUTH_TOKEN"]
+                - '--slack-channel'
+                - {{ .Values.cronJob.slackChannel | quote }}
+                - '--slack-auth-token'
+                - "${SLACK_AUTH_TOKEN}"
               env:
                 - name: SLACK_AUTH_TOKEN
                   valueFrom:
                     secretKeyRef:
                       name: {{ .Release.Name }}-slack-auth-token-secret
                       key: slack-auth-token
-              {{- else }}
-              args: ["{{ .Values.cronJob.command }}"]
               {{- end }}
               {{- if .Values.cronJob.env }}
               env:
diff --git a/charts/kor/templates/deployment.yaml b/charts/kor/templates/deployment.yaml
index 3abf505..4cc29e9 100644
--- a/charts/kor/templates/deployment.yaml
+++ b/charts/kor/templates/deployment.yaml
@@ -27,8 +27,10 @@ spec:
             {{ toYaml . | nindent 12 }}
           {{- end }}
           image: "{{ .Values.prometheusExporter.deployment.image.repository }}:{{ .Values.prometheusExporter.deployment.image.tag }}"
-          command: ["/bin/sh", "-c"]
-          args: ["{{ .Values.prometheusExporter.deployment.command }}"]
+          command:
+            {{- toYaml .Values.prometheusExporter.command | nindent 12 }}
+          args:
+            {{- toYaml .Values.prometheusExporter.args | nindent 12 }}
           ports:
           - containerPort: 8080
             name: http
@@ -40,7 +42,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:
@@ -63,4 +65,4 @@ spec:
       securityContext: 
         {{- toYaml . | nindent 8}}
       {{- end }}
-{{- end }}
\ No newline at end of file
+{{- end }}
diff --git a/charts/kor/values.yaml b/charts/kor/values.yaml
index 771638d..1a5da8f 100644
--- a/charts/kor/values.yaml
+++ b/charts/kor/values.yaml
@@ -6,7 +6,10 @@ cronJob:
     repository: yonahdissen/kor
     tag: latest
   # e.g. kor configmap --include-namespace default,other-ns
-  command: kor all
+  command:
+    - kor
+  args:
+    - all
   slackWebhookUrl: ""
   slackChannel: ""
   slackAuthToken: ""
@@ -19,11 +22,14 @@ prometheusExporter:
   name: kor-exporter
   # time in minutes, default is 10 minutes
   exporterInterval: ""
+  command:
+    - kor
+  args:
+    - exporter
   deployment:
     image:
       repository: yonahdissen/kor
       tag: latest
-    command: kor exporter
     restartPolicy: Always
     imagePullPolicy: Always
     imagePullSecrets: []
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)
+}