Repository: yonahd/kor
Pull request #168 (2023-11-29 15:32:46)

feat: add jobs cleaner
add a job resource cleaner, when a job's status.completedTime field has value, consider the job is completed, is safe to delete


Patch:
diff --git a/README.md b/README.md
index 46365d4..6344e2c 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
 ![GitHub release (with filter)](https://img.shields.io/github/v/release/yonahd/kor?color=green&link=https://github.com/yonahd/kor/releases)
 ![Docker Pulls](https://img.shields.io/docker/pulls/yonahdissen/kor)
 [![codecov](https://codecov.io/gh/yonahd/kor/branch/main/graph/badge.svg?token=tNKcOjlxLo)](https://codecov.io/gh/yonahd/kor)
+[![Discord](https://discord.com/api/guilds/1159544275722321990/embed.png)](https://discord.gg/ajptYPwcJY)
+ 
 
 
 # Kor - Kubernetes Orphaned Resources Finder
@@ -22,6 +24,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
 - PDBs
 - CRDs
 - PVs
+- Jobs
 
 ![Kor Screenshot](/images/screenshot.png)
 
@@ -87,6 +90,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
 - `ingress` - Gets unused Ingresses for the specified namespace or all namespaces.
 - `pdb` - Gets unused PDBs for the specified namespace or all namespaces.
 - `crd` - Gets unused CRDs in the cluster(non namespaced resource).
+- `jobs` - Gets unused jobs for the specified namespace or all namespaces.
 - `exporter` - Export Prometheus metrics.
 
 ### Supported Flags
@@ -122,10 +126,10 @@ kor [subcommand] --help
 
 ## Supported resources and limitations
 
-| Resource        | What it looks for                                                                                                                                                                                                                 | Known False Positives  ⚠️                                                                                                     |
+| Resource        | What it looks for                                                                                                                                                                                                                 | Known False Positives  ⚠️                                                                                                    |
 |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
 | ConfigMaps      | ConfigMaps not used in the following places:<br/>- Pods<br/>- Containers<br/>- ConfigMaps used through Volumes<br/>- ConfigMaps used through environment variables                                                                | ConfigMaps used by resources which don't explicitly state them in the config.<br/> e.g Grafana dashboards loaded dynamically OPA policies fluentd configs |
-| Secrets         | Secrets not used in the following places:<br/>- Pods<br/>- Containers<br/>- Secrets used through volumes<br/>- Secrets used through environment variables<br/>- Secrets used by Ingress TLS<br/>- Secrets used by ServiceAccounts |    Secrets used by resources which don't explicitly state them in the config                                                                                                                         |
+| Secrets         | Secrets not used in the following places:<br/>- Pods<br/>- Containers<br/>- Secrets used through volumes<br/>- Secrets used through environment variables<br/>- Secrets used by Ingress TLS<br/>- Secrets used by ServiceAccounts |    Secrets used by resources which don't explicitly state them in the config                                                                                                                        |
 | Services        | Services with no endpoints                                                                                                                                                                                                        |                                                                                                                              |
 | Deployments     | Deployments with no Replicas                                                                                                                                                                                                      |                                                                                                                              |
 | ServiceAccounts | ServiceAccounts unused by Pods<br/>ServiceAccounts unused by roleBinding or clusterRoleBinding                                                                                                                                    |                                                                                                                              |
@@ -137,7 +141,7 @@ kor [subcommand] --help
 | CRDs            | CRDs not used the cluster                                                                                                                                                                                                         |                                                                                                                              |
 | Pvs             | PVs not bound to a PVC                                                                                                                                                                                                            |                                                                                                                              |
 | Pdbs            | PDBs not used in Deployments<br/> PDBs not used in StatefulSets                                                                                                                                                                   |                                                                                                                              |
-
+| Jobs            | Jobs status is completed                                                                                                                                                                                                          |                                                                                                                              |
 
 ## Deleting Unused resources
 If you want to delete resources in an interactive way using Kor you can run:
diff --git a/cmd/kor/jobs.go b/cmd/kor/jobs.go
new file mode 100644
index 0000000..64b10f0
--- /dev/null
+++ b/cmd/kor/jobs.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 jobCmd = &cobra.Command{
+	Use:     "job",
+	Aliases: []string{"job", "jobs"},
+	Short:   "Gets unused jobs",
+	Args:    cobra.ExactArgs(0),
+	Run: func(cmd *cobra.Command, args []string) {
+		clientset := kor.GetKubeClient(kubeconfig)
+		if response, err := kor.GetUnusedJobs(includeExcludeLists, filterOptions, clientset, outputFormat, opts); err != nil {
+			fmt.Println(err)
+		} else {
+			utils.PrintLogo(outputFormat)
+			fmt.Println(response)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(jobCmd)
+}
diff --git a/pkg/kor/all.go b/pkg/kor/all.go
index 047d735..73c64e9 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 getUnusedJobs(clientset kubernetes.Interface, namespace string, filterOpts *FilterOptions) ResourceDiff {
+	jobDiff, err := ProcessNamespaceJobs(clientset, namespace, filterOpts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "jobs", namespace, err)
+	}
+	namespaceSADiff := ResourceDiff{"Job", jobDiff}
+	return namespaceSADiff
+}
+
 func GetUnusedAll(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOptions, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) {
 	var outputBuffer bytes.Buffer
 
@@ -168,6 +177,8 @@ func GetUnusedAll(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOpt
 		allDiffs = append(allDiffs, namespaceIngressDiff)
 		namespacePdbDiff := getUnusedPdbs(clientset, namespace, filterOpts)
 		allDiffs = append(allDiffs, namespacePdbDiff)
+		namespaceJobDiff := getUnusedJobs(clientset, namespace, filterOpts)
+		allDiffs = append(allDiffs, namespaceJobDiff)
 
 		output := FormatOutputAll(namespace, allDiffs, opts)
 
diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go
index 7c0dd24..8d4e489 100644
--- a/pkg/kor/delete.go
+++ b/pkg/kor/delete.go
@@ -3,6 +3,7 @@ package kor
 import (
 	"context"
 	"fmt"
+	batchv1 "k8s.io/api/batch/v1"
 	"os"
 	"reflect"
 	"strings"
@@ -10,7 +11,7 @@ import (
 	appsv1 "k8s.io/api/apps/v1"
 	autoscalingv1 "k8s.io/api/autoscaling/v1"
 	corev1 "k8s.io/api/core/v1"
-	networkingv1beta1 "k8s.io/api/networking/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	policyv1beta1 "k8s.io/api/policy/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,7 +36,7 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
 			return clientset.AutoscalingV1().HorizontalPodAutoscalers(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
 		"Ingress": func(clientset kubernetes.Interface, namespace, name string) error {
-			return clientset.NetworkingV1beta1().Ingresses(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
+			return clientset.NetworkingV1().Ingresses(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
 		"PDB": func(clientset kubernetes.Interface, namespace, name string) error {
 			return clientset.PolicyV1beta1().PodDisruptionBudgets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
@@ -55,6 +56,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{})
 		},
+		"Job": func(clientset kubernetes.Interface, namespace, name string) error {
+			return clientset.BatchV1().Jobs(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
+		},
 	}
 
 	return deleteResourceApiMap
@@ -95,7 +99,7 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
 	case "HPA":
 		return clientset.AutoscalingV1().HorizontalPodAutoscalers(namespace).Update(context.TODO(), resource.(*autoscalingv1.HorizontalPodAutoscaler), metav1.UpdateOptions{})
 	case "Ingress":
-		return clientset.NetworkingV1beta1().Ingresses(namespace).Update(context.TODO(), resource.(*networkingv1beta1.Ingress), metav1.UpdateOptions{})
+		return clientset.NetworkingV1().Ingresses(namespace).Update(context.TODO(), resource.(*networkingv1.Ingress), metav1.UpdateOptions{})
 	case "PDB":
 		return clientset.PolicyV1beta1().PodDisruptionBudgets(namespace).Update(context.TODO(), resource.(*policyv1beta1.PodDisruptionBudget), metav1.UpdateOptions{})
 	case "Roles":
@@ -108,6 +112,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 "Job":
+		return clientset.BatchV1().Jobs(namespace).Update(context.TODO(), resource.(*batchv1.Job), metav1.UpdateOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
@@ -125,7 +131,7 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
 	case "HPA":
 		return clientset.AutoscalingV1().HorizontalPodAutoscalers(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "Ingress":
-		return clientset.NetworkingV1beta1().Ingresses(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
+		return clientset.NetworkingV1().Ingresses(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "PDB":
 		return clientset.PolicyV1beta1().PodDisruptionBudgets(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "Roles":
@@ -138,6 +144,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 "Job":
+		return clientset.BatchV1().Jobs(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
diff --git a/pkg/kor/ingresses.go b/pkg/kor/ingresses.go
index 32112cc..7d20faf 100644
--- a/pkg/kor/ingresses.go
+++ b/pkg/kor/ingresses.go
@@ -34,10 +34,6 @@ func retrieveUsedIngress(clientset kubernetes.Interface, namespace string, filte
 	usedIngresses := []string{}
 
 	for _, ingress := range ingresses.Items {
-		if ingress.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(ingress.Labels, filterOpts.ExcludeLabels); excluded {
@@ -83,6 +79,9 @@ func retrieveIngressNames(clientset kubernetes.Interface, namespace string) ([]s
 	}
 	names := make([]string, 0, len(ingresses.Items))
 	for _, ingress := range ingresses.Items {
+		if ingress.Labels["kor/used"] == "true" {
+			continue
+		}
 		names = append(names, ingress.Name)
 	}
 	return names, nil
diff --git a/pkg/kor/jobs.go b/pkg/kor/jobs.go
new file mode 100644
index 0000000..8682270
--- /dev/null
+++ b/pkg/kor/jobs.go
@@ -0,0 +1,85 @@
+package kor
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"os"
+)
+
+func ProcessNamespaceJobs(clientset kubernetes.Interface, namespace string, filterOpts *FilterOptions) ([]string, error) {
+	jobsList, err := clientset.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	var unusedJobNames []string
+
+	for _, job := range jobsList.Items {
+		if job.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(job.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(job.CreationTimestamp, filterOpts); !included {
+			continue
+		}
+
+		// if the job has completionTime and succeeded count greater than zero, think the job is completed
+		if job.Status.CompletionTime != nil && job.Status.Succeeded > 0 {
+			unusedJobNames = append(unusedJobNames, job.Name)
+		}
+	}
+
+	return unusedJobNames, nil
+}
+
+func GetUnusedJobs(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 := ProcessNamespaceJobs(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, "Job", opts.NoInteractive); err != nil {
+				fmt.Fprintf(os.Stderr, "Failed to delete Job %s in namespace %s: %v\n", diff, namespace, err)
+			}
+		}
+		output := FormatOutput(namespace, diff, "Job", opts)
+		if output != "" {
+			outputBuffer.WriteString(output)
+			outputBuffer.WriteString("\n")
+
+			resourceMap := make(map[string][]string)
+			resourceMap["Jobs"] = diff
+			response[namespace] = resourceMap
+		}
+	}
+
+	jsonResponse, err := json.MarshalIndent(response, "", "  ")
+	if err != nil {
+		return "", err
+	}
+
+	unusedJobs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
+	if err != nil {
+		fmt.Printf("err: %v\n", err)
+	}
+
+	return unusedJobs, nil
+}
diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go
index 26bc206..40419d0 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 "job", "jobs":
+			diffResult = getUnusedJobs(clientset, namespace, filterOpts)
 		default:
 			fmt.Printf("resource type %q is not supported\n", resource)
 		}


Test patch:
diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go
index 2659f86..7a78142 100644
--- a/pkg/kor/create_test_resources.go
+++ b/pkg/kor/create_test_resources.go
@@ -3,6 +3,7 @@ package kor
 import (
 	appsv1 "k8s.io/api/apps/v1"
 	autoscalingv2 "k8s.io/api/autoscaling/v2"
+	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	networkingv1 "k8s.io/api/networking/v1"
 	policyv1 "k8s.io/api/policy/v1"
@@ -259,3 +260,26 @@ func CreateTestConfigmap(namespace, name string) *corev1.ConfigMap {
 		},
 	}
 }
+
+func CreateTestJob(namespace, name string, status *batchv1.JobStatus) *batchv1.Job {
+	return &batchv1.Job{
+		ObjectMeta: v1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: batchv1.JobSpec{
+			Template: corev1.PodTemplateSpec{
+				Spec: corev1.PodSpec{
+					Containers: []corev1.Container{
+						{
+							Name:  "test",
+							Image: "test",
+						},
+					},
+					RestartPolicy: corev1.RestartPolicyNever,
+				},
+			},
+		},
+		Status: *status,
+	}
+}
diff --git a/pkg/kor/jobs_test.go b/pkg/kor/jobs_test.go
new file mode 100644
index 0000000..d6498bf
--- /dev/null
+++ b/pkg/kor/jobs_test.go
@@ -0,0 +1,108 @@
+package kor
+
+import (
+	"context"
+	"encoding/json"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/kubernetes/scheme"
+	"reflect"
+	"testing"
+	"time"
+)
+
+func createTestJobs(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)
+	}
+
+	job1 := CreateTestJob(testNamespace, "test-job1", &batchv1.JobStatus{
+		Succeeded: 0,
+		Failed:    1,
+	})
+	job2 := CreateTestJob(testNamespace, "test-job2", &batchv1.JobStatus{
+		Succeeded:      1,
+		Failed:         0,
+		CompletionTime: &v1.Time{Time: time.Now()},
+	})
+
+	_, err = clientset.BatchV1().Jobs(testNamespace).Create(context.TODO(), job1, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake job: %v", err)
+	}
+
+	_, err = clientset.BatchV1().Jobs(testNamespace).Create(context.TODO(), job2, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake job: %v", err)
+	}
+	return clientset
+}
+
+func TestProcessNamespaceJobs(t *testing.T) {
+	clientset := createTestJobs(t)
+
+	completedJobs, err := ProcessNamespaceJobs(clientset, testNamespace, &FilterOptions{})
+	if err != nil {
+		t.Errorf("Expected no error, got %v", err)
+	}
+
+	if len(completedJobs) != 1 {
+		t.Errorf("Expected 1 job been completed, got %d", len(completedJobs))
+	}
+
+	if completedJobs[0] != "test-job2" {
+		t.Errorf("job2', got %s", completedJobs[0])
+	}
+}
+
+func TestGetUnusedJobsStructured(t *testing.T) {
+	clientset := createTestJobs(t)
+
+	includeExcludeLists := IncludeExcludeLists{
+		IncludeListStr: "",
+		ExcludeListStr: "",
+	}
+
+	opts := Opts{
+		WebhookURL:    "",
+		Channel:       "",
+		Token:         "",
+		DeleteFlag:    false,
+		NoInteractive: true,
+	}
+
+	output, err := GetUnusedJobs(includeExcludeLists, &FilterOptions{}, clientset, "json", opts)
+	if err != nil {
+		t.Fatalf("Error calling GetUnusedJobsStructured: %v", err)
+	}
+
+	expectedOutput := map[string]map[string][]string{
+		testNamespace: {
+			"Jobs": {"test-job2"},
+		},
+	}
+
+	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")
+	}
+}
+
+func init() {
+	scheme.Scheme = runtime.NewScheme()
+	_ = appsv1.AddToScheme(scheme.Scheme)
+}