Skip to content

Commit

Permalink
feat: add jobs cleaner (yonahd#168)
Browse files Browse the repository at this point in the history
* feat: add jobs cleaner

* chore: go vet thing

* fix: rename variable name

---------

Co-authored-by: liumengna <[email protected]>
  • Loading branch information
cindyliu-tec and liumengna authored Dec 4, 2023
1 parent 95fda98 commit 6ee6450
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 3 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,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)

Expand Down Expand Up @@ -89,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
Expand Down Expand Up @@ -124,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 | |
Expand All @@ -139,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:
Expand Down
29 changes: 29 additions & 0 deletions cmd/kor/jobs.go
Original file line number Diff line number Diff line change
@@ -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)
}
11 changes: 11 additions & 0 deletions pkg/kor/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
24 changes: 24 additions & 0 deletions pkg/kor/create_test_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}
}
8 changes: 8 additions & 0 deletions pkg/kor/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kor
import (
"context"
"fmt"
batchv1 "k8s.io/api/batch/v1"
"os"
"reflect"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
85 changes: 85 additions & 0 deletions pkg/kor/jobs.go
Original file line number Diff line number Diff line change
@@ -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
}
108 changes: 108 additions & 0 deletions pkg/kor/jobs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions pkg/kor/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 6ee6450

Please sign in to comment.