Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add jobs cleaner #168

Merged
merged 3 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 @@ -87,6 +88,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 @@ -122,10 +124,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 @@ -137,7 +139,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