Repository: yonahd/kor
Pull request #199 (2024-02-02 23:48:19)

Feat: added support for unused DaemonSets
This PR adds a new functionality to `kor` by finding unused DaemonSets.
I've considered DaemonSets to be unused, if the following condition is met:
```
status:
  currentNumberScheduled: 0
```

Meaning the number of nodes that should be running at least 1 daemon pod and are supposed to run the daemon pod, is equal to zero.
More information about DaemonSets' status fields could be found in these [docs](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#daemonsetstatus-v1-apps).

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) Report
Attention: `36 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`47ec5b8`)](https://app.codecov.io/gh/yonahd/kor/commit/47ec5b81d081f4d43ad6c012b2317c10ef80f6c6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) 44.43% compared to head [(`dfb32f4`)](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) 45.00%.

| [Files](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) | Patch % | Lines |
|---|---|---|
| [pkg/kor/daemonsets.go](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen#diff-cGtnL2tvci9kYWVtb25zZXRzLmdv) | 60.86% | [12 Missing and 6 partials :warning: ](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) |
| [pkg/kor/all.go](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen#diff-cGtnL2tvci9hbGwuZ28=) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) |
| [pkg/kor/delete.go](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen#diff-cGtnL2tvci9kZWxldGUuZ28=) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) |
| [pkg/kor/multi.go](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen#diff-cGtnL2tvci9tdWx0aS5nbw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/yonahd/kor/pull/199?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Yonah+Dissen) |

: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     #199      +/-   ##
==========================================
+ Coverage   44.43%   45.00%   +0.57%     
==========================================
  Files          29       30       +1     
  Lines        2444     2535      +91     
==========================================
+ Hits         1086     1141      +55     
- Misses       1205     1235      +30     
- Partials      153      159       +6     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/yonahd/kor/pull/199?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).

doronkg: Conflicts might raise in files `pkg/kor/delete.go` (#201) & `charts/kor/templates/role.yaml` (#202).
Should be taken into account when merging.

Patch:
diff --git a/.gitignore b/.gitignore
index f363e84..5895e9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 *.iml
 .idea/
-dist/**
\ No newline at end of file
+dist/**
+main.exe
diff --git a/README.md b/README.md
index 6b4bc4f..dfacfec 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 ![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
@@ -11,7 +11,7 @@
 ![Kor Logo](/images/kor_logo.png)
 
 Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identify and list unused:
-- ConfigMaps  
+- ConfigMaps
 - Secrets
 - Services
 - ServiceAccounts
@@ -27,6 +27,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
 - Pods
 - Jobs
 - ReplicaSets
+- DaemonSets
 
 ![Kor Screenshot](/images/screenshot.png)
 
@@ -72,7 +73,7 @@ helm upgrade -i kor \
 ```
 
 
-For more information see [in cluster usage](#in-cluster-usage) 
+For more information see [in cluster usage](#in-cluster-usage)
 
 ## Usage
 
@@ -95,6 +96,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
 - `crd` - Gets unused CRDs in the cluster(non namespaced resource).
 - `jobs` - Gets unused jobs for the specified namespace or all namespaces.
 - `replicasets` - Gets unused replicaSets for the specified namespace or all namespaces.
+- `daemonsets`- Gets unused DaemonSets for the specified namespace or all namespaces.
 - `finalizers` - Gets unused pending deletion resources for the specified namespace or all namespaces.
 - `exporter` - Export Prometheus metrics.
 
@@ -104,7 +106,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
   -l, --exclude-labels string       Selector to filter out, Example: --exclude-labels key1=value1,key2=value2.
   -e, --exclude-namespaces string   Namespaces to be excluded, splited by comma. Example: --exclude-namespace ns1,ns2,ns3. If --include-namespace is set, --exclude-namespaces will be ignored.
   -h, --help                        help for kor
-  -n, --include-namespaces string   Namespaces to run on, splited by comma. Example: --include-namespace ns1,ns2,ns3. 
+  -n, --include-namespaces string   Namespaces to run on, splited by comma. Example: --include-namespace ns1,ns2,ns3.
   -k, --kubeconfig string           Path to kubeconfig file (optional)
       --newer-than string           The maximum age of the resources to be considered unused. This flag cannot be used together with older-than flag. Example: --newer-than=1h2m
       --no-interactive              Do not prompt for confirmation when deleting resources. Be careful using this flag!
@@ -147,8 +149,8 @@ kor [subcommand] --help
 | Pvs             | PVs not bound to a PVC                                                                                                                                                                                                            |                                                                                                                              |
 | Pdbs            | PDBs not used in Deployments<br/> PDBs not used in StatefulSets                                                                                                                                                                   |                                                                                                                              |
 | Jobs            | Jobs status is completed                                                                                                                                                                                                          |                                                                                                                              |
-| ReplicaSets     | replicaSets that specify replicas to 0 and has already completed it's work              |    
-
+| ReplicaSets     | replicaSets that specify replicas to 0 and has already completed it's work              |
+| DaemonSets     | DaemonSets not scheduled on any nodes              |
 
 ## Deleting Unused resources
 If you want to delete resources in an interactive way using Kor you can run:
@@ -166,7 +168,7 @@ kor configmap --include-namespaces my-namespace --delete --no-interactive
 ```
 
 ## Ignore Resources
-The resources labeled with: 
+The resources labeled with:
 ```sh
 kor/used=true
 ```
@@ -219,4 +221,3 @@ Contributions are welcome! If you encounter any bugs or have suggestions for imp
 ## License
 
 This open-source project is available under the [MIT License](LICENSE). Feel free to use, modify, and distribute it as per the terms of the license.
-
diff --git a/charts/kor/templates/role.yaml b/charts/kor/templates/role.yaml
index a27dfa7..5ab88c2 100644
--- a/charts/kor/templates/role.yaml
+++ b/charts/kor/templates/role.yaml
@@ -24,6 +24,7 @@ rules:
       - endpoints
       - jobs
       - replicasets
+      - daemonsets
     verbs:
       - get
       - list
@@ -54,6 +55,7 @@ rules:
       - endpoints
       - jobs
       - replicasets
+      - daemonsets      
       {{/* cluster-scoped resources */}}
       - namespaces
       - clusterroles
diff --git a/cmd/kor/daemonsets.go b/cmd/kor/daemonsets.go
new file mode 100644
index 0000000..374934e
--- /dev/null
+++ b/cmd/kor/daemonsets.go
@@ -0,0 +1,31 @@
+package kor
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/yonahd/kor/pkg/kor"
+	"github.com/yonahd/kor/pkg/utils"
+)
+
+var dsCmd = &cobra.Command{
+	Use:     "daemonset",
+	Aliases: []string{"ds", "daemonsets"},
+	Short:   "Gets unused daemonSets",
+	Args:    cobra.ExactArgs(0),
+	Run: func(cmd *cobra.Command, args []string) {
+		clientset := kor.GetKubeClient(kubeconfig)
+
+		if response, err := kor.GetUnusedDaemonSets(filterOptions, clientset, outputFormat, opts); err != nil {
+			fmt.Println(err)
+		} else {
+			utils.PrintLogo(outputFormat)
+			fmt.Println(response)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(dsCmd)
+}
diff --git a/pkg/kor/all.go b/pkg/kor/all.go
index 987c083..edfc8d9 100644
--- a/pkg/kor/all.go
+++ b/pkg/kor/all.go
@@ -167,6 +167,15 @@ func getUnusedReplicaSets(clientset kubernetes.Interface, namespace string, filt
 	return namespaceRSDiff
 }
 
+func getUnusedDaemonSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff {
+	dsDiff, err := ProcessNamespaceDaemonSets(clientset, namespace, filterOpts)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "DaemonSets", namespace, err)
+	}
+	namespaceSADiff := ResourceDiff{"DaemonSet", dsDiff}
+	return namespaceSADiff
+}
+
 func GetUnusedAll(filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) {
 	var outputBuffer bytes.Buffer
 
@@ -201,6 +210,8 @@ func GetUnusedAll(filterOpts *filters.Options, clientset kubernetes.Interface, a
 		allDiffs = append(allDiffs, namespaceJobDiff)
 		namespaceRSDiff := getUnusedReplicaSets(clientset, namespace, filterOpts)
 		allDiffs = append(allDiffs, namespaceRSDiff)
+		namespaceDaemonsetDiff := getUnusedDaemonSets(clientset, namespace, filterOpts)
+		allDiffs = append(allDiffs, namespaceDaemonsetDiff)
 
 		output := FormatOutputAll(namespace, allDiffs, opts)
 
diff --git a/pkg/kor/daemonsets.go b/pkg/kor/daemonsets.go
new file mode 100644
index 0000000..5963558
--- /dev/null
+++ b/pkg/kor/daemonsets.go
@@ -0,0 +1,75 @@
+package kor
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+
+	"github.com/yonahd/kor/pkg/filters"
+)
+
+func ProcessNamespaceDaemonSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) {
+	daemonSetsList, err := clientset.AppsV1().DaemonSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels})
+	if err != nil {
+		return nil, err
+	}
+
+	var daemonSetsWithoutReplicas []string
+
+	for _, daemonSet := range daemonSetsList.Items {
+		if pass, _ := filter.Run(filterOpts); pass {
+			continue
+		}
+
+		if daemonSet.Status.CurrentNumberScheduled == 0 {
+			daemonSetsWithoutReplicas = append(daemonSetsWithoutReplicas, daemonSet.Name)
+		}
+	}
+
+	return daemonSetsWithoutReplicas, nil
+}
+
+func GetUnusedDaemonSets(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) {
+	var outputBuffer bytes.Buffer
+	namespaces := filterOpts.Namespaces(clientset)
+	response := make(map[string]map[string][]string)
+
+	for _, namespace := range namespaces {
+		diff, err := ProcessNamespaceDaemonSets(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, "DaemonSet", opts.NoInteractive); err != nil {
+				fmt.Fprintf(os.Stderr, "Failed to delete DaemonSet %s in namespace %s: %v\n", diff, namespace, err)
+			}
+		}
+		output := FormatOutput(namespace, diff, "DaemonSets", opts)
+		if output != "" {
+			outputBuffer.WriteString(output)
+			outputBuffer.WriteString("\n")
+
+			resourceMap := make(map[string][]string)
+			resourceMap["DaemonSets"] = diff
+			response[namespace] = resourceMap
+		}
+	}
+
+	jsonResponse, err := json.MarshalIndent(response, "", "  ")
+	if err != nil {
+		return "", err
+	}
+
+	unusedDaemonSets, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
+	if err != nil {
+		fmt.Printf("err: %v\n", err)
+	}
+
+	return unusedDaemonSets, nil
+}
diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go
index 9fd32f1..68ae9ec 100644
--- a/pkg/kor/delete.go
+++ b/pkg/kor/delete.go
@@ -69,6 +69,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
 		"ReplicaSet": func(clientset kubernetes.Interface, namespace, name string) error {
 			return clientset.AppsV1().ReplicaSets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
 		},
+		"DaemonSet": func(clientset kubernetes.Interface, namespace, name string) error {
+			return clientset.AppsV1().DaemonSets(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
+		},
 	}
 
 	return deleteResourceApiMap
@@ -150,6 +153,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
 		return clientset.BatchV1().Jobs(namespace).Update(context.TODO(), resource.(*batchv1.Job), metav1.UpdateOptions{})
 	case "ReplicaSet":
 		return clientset.AppsV1().ReplicaSets(namespace).Update(context.TODO(), resource.(*appsv1.ReplicaSet), metav1.UpdateOptions{})
+	case "DaemonSet":
+		return clientset.AppsV1().DaemonSets(namespace).Update(context.TODO(), resource.(*appsv1.DaemonSet), metav1.UpdateOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
@@ -186,6 +191,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
 		return clientset.BatchV1().Jobs(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	case "ReplicaSet":
 		return clientset.AppsV1().ReplicaSets(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
+	case "DaemonSet":
+		return clientset.AppsV1().DaemonSets(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
 	}
 	return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
 }
diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go
index 7c65b09..2baad9a 100644
--- a/pkg/kor/multi.go
+++ b/pkg/kor/multi.go
@@ -76,6 +76,8 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re
 			diffResult = getUnusedJobs(clientset, namespace, filterOpts)
 		case "rs", "replicaset", "replicasets":
 			diffResult = getUnusedReplicaSets(clientset, namespace, filterOpts)
+		case "ds", "daemonset", "daemonsets":
+			diffResult = getUnusedDaemonSets(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 2c4f581..0ba9c0b 100644
--- a/pkg/kor/create_test_resources.go
+++ b/pkg/kor/create_test_resources.go
@@ -321,4 +321,15 @@ func CreateTestReplicaSet(namespace, name string, specReplicas *int32, status *a
 		},
 		Status: *status,
 	}
-}
\ No newline at end of file
+}
+
+func CreateTestDaemonSet(namespace, name string, labels map[string]string, status *appsv1.DaemonSetStatus) *appsv1.DaemonSet {
+	return &appsv1.DaemonSet{
+		ObjectMeta: v1.ObjectMeta{
+			Namespace: namespace,
+			Name:      name,
+			Labels:    labels,
+		},
+		Status: *status,
+	}
+}
diff --git a/pkg/kor/daemonsets_test.go b/pkg/kor/daemonsets_test.go
new file mode 100644
index 0000000..dada0c0
--- /dev/null
+++ b/pkg/kor/daemonsets_test.go
@@ -0,0 +1,104 @@
+package kor
+
+import (
+	"context"
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	appsv1 "k8s.io/api/apps/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"
+
+	"github.com/yonahd/kor/pkg/filters"
+)
+
+func createTestDaemonSets(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)
+	}
+
+	appLabels := map[string]string{}
+
+	ds1 := CreateTestDaemonSet(testNamespace, "test-ds1", appLabels, &appsv1.DaemonSetStatus{
+		CurrentNumberScheduled: 0,
+	})
+	ds2 := CreateTestDaemonSet(testNamespace, "test-ds2", appLabels, &appsv1.DaemonSetStatus{
+		CurrentNumberScheduled: 1,
+	})
+
+	_, err = clientset.AppsV1().DaemonSets(testNamespace).Create(context.TODO(), ds1, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake %s: %v", "DaemonSet", err)
+	}
+
+	_, err = clientset.AppsV1().DaemonSets(testNamespace).Create(context.TODO(), ds2, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake %s: %v", "DaemonSet", err)
+	}
+
+	return clientset
+}
+
+func TestProcessNamespaceDaemonSets(t *testing.T) {
+	clientset := createTestDaemonSets(t)
+
+	daemonSetsWithoutReplicas, err := ProcessNamespaceDaemonSets(clientset, testNamespace, &filters.Options{})
+	if err != nil {
+		t.Errorf("Expected no error, got %v", err)
+	}
+
+	if len(daemonSetsWithoutReplicas) != 1 {
+		t.Errorf("Expected 1 DaemonSet without replicas, got %d", len(daemonSetsWithoutReplicas))
+	}
+
+	if daemonSetsWithoutReplicas[0] != "test-ds1" {
+		t.Errorf("Expected 'test-ds1', got %s", daemonSetsWithoutReplicas[0])
+	}
+}
+
+func TestGetUnusedDaemonSetsStructured(t *testing.T) {
+	clientset := createTestDaemonSets(t)
+
+	opts := Opts{
+		WebhookURL:    "",
+		Channel:       "",
+		Token:         "",
+		DeleteFlag:    false,
+		NoInteractive: true,
+	}
+
+	output, err := GetUnusedDaemonSets(&filters.Options{}, clientset, "json", opts)
+	if err != nil {
+		t.Fatalf("Error calling GetUnusedDaemonSetsStructured: %v", err)
+	}
+
+	expectedOutput := map[string]map[string][]string{
+		testNamespace: {
+			"DaemonSets": {"test-ds1"},
+		},
+	}
+
+	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)
+}