From c574c6901ecfbf7a0ced1798efe5ec639daae76c Mon Sep 17 00:00:00 2001 From: liornoy Date: Thu, 12 Oct 2023 14:55:48 +0300 Subject: [PATCH 1/2] feat: Support finding unused namespaces Add a new "namespace" command to find unused namespaces. This command determines whether there are any used resources within a given namespace, if there are no used resources, the namespace is considered unused. Additionally, unit tests have been added to cover different resource scenarios and for the Structured function. Signed-off-by: liornoy --- cmd/kor/namespaces.go | 31 ++++++++ pkg/kor/all.go | 14 ++++ pkg/kor/namespaces.go | 119 +++++++++++++++++++++++++++++ pkg/kor/namespaces_test.go | 149 +++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 cmd/kor/namespaces.go create mode 100644 pkg/kor/namespaces.go create mode 100644 pkg/kor/namespaces_test.go diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go new file mode 100644 index 00000000..87ff15f1 --- /dev/null +++ b/cmd/kor/namespaces.go @@ -0,0 +1,31 @@ +package kor + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/yonahd/kor/pkg/kor" +) + +var namespaceCmd = &cobra.Command{ + Use: "namespace", + Aliases: []string{"ns", "namespaces"}, + Short: "Gets unused namespaces", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + clientset := kor.GetKubeClient(kubeconfig) + if outputFormat == "json" || outputFormat == "yaml" { + if response, err := kor.GetUnusedNamespacesStructured(includeExcludeLists, clientset, outputFormat); err != nil { + fmt.Println(err) + } else { + fmt.Println(response) + } + } else { + kor.GetUnusedNamespaces(includeExcludeLists, clientset, slackOpts) + } + }, +} + +func init() { + rootCmd.AddCommand(namespaceCmd) +} diff --git a/pkg/kor/all.go b/pkg/kor/all.go index abbd0842..40c9df7f 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -119,6 +119,15 @@ func getUnusedPdbs(clientset kubernetes.Interface, namespace string) ResourceDif return namespacePdbDiff } +func getUnusedNSs(clientset kubernetes.Interface, namespace string) ResourceDiff { + nsDiff, err := processNamespaceNS(clientset, namespace) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get namespace %s: %v\n", namespace, err) + } + namespaceNsDiff := ResourceDiff{"Namespace", nsDiff} + return namespaceNsDiff +} + func GetUnusedAll(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) @@ -148,6 +157,8 @@ func GetUnusedAll(includeExcludeLists IncludeExcludeLists, clientset kubernetes. allDiffs = append(allDiffs, namespaceIngressDiff) namespacePdbDiff := getUnusedPdbs(clientset, namespace) allDiffs = append(allDiffs, namespacePdbDiff) + namespaceNsDiff := getUnusedNSs(clientset, namespace) + allDiffs = append(allDiffs, namespaceNsDiff) output := FormatOutputAll(namespace, allDiffs) @@ -207,6 +218,9 @@ func GetUnusedAllStructured(includeExcludeLists IncludeExcludeLists, clientset k namespacePdbDiff := getUnusedPdbs(clientset, namespace) allDiffs = append(allDiffs, namespacePdbDiff) + namespaceNSDiff := getUnusedNSs(clientset, namespace) + allDiffs = append(allDiffs, namespaceNSDiff) + // Store the unused resources for each resource type in the JSON response resourceMap := make(map[string][]string) for _, diff := range allDiffs { diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go new file mode 100644 index 00000000..701c77d8 --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,119 @@ +package kor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + "sigs.k8s.io/yaml" +) + +type processFn func(kubernetes.Interface, string) ([]string, error) + +func GetUnusedNamespaces(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { + namespaces := SetNamespaceList(includeExcludeLists, clientset) + + var outputBuffer bytes.Buffer + + for _, namespace := range namespaces { + diff, err := processNamespaceNS(clientset, namespace) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err) + continue + } + output := FormatOutput(namespace, diff, "Namespaces") + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) + } +} + +func GetUnusedNamespacesStructured(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, outputFormat string) (string, error) { + namespaces := SetNamespaceList(includeExcludeLists, clientset) + response := make(map[string]map[string][]string) + + for _, namespace := range namespaces { + diff, err := processNamespaceNS(clientset, namespace) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err) + continue + } + resourceMap := make(map[string][]string) + resourceMap["Namespaces"] = diff + response[namespace] = resourceMap + } + + jsonResponse, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", err + } + + if outputFormat == "yaml" { + yamlResponse, err := yaml.JSONToYAML(jsonResponse) + if err != nil { + fmt.Printf("err: %v\n", err) + } + return string(yamlResponse), nil + } else { + return string(jsonResponse), nil + } +} + +func processNamespaceNS(clientset kubernetes.Interface, namespace string) ([]string, error) { + usedNamespace, err := retrieveUsedNS(clientset, namespace) + if err != nil { + return nil, err + } + diff := CalculateResourceDifference(usedNamespace, []string{namespace}) + return diff, nil +} + +func retrieveUsedNS(clientset kubernetes.Interface, namespace string) ([]string, error) { + processFunctions := []processFn{ + processNamespaceCM, + processNamespaceHpas, + processNamespaceIngresses, + processNamespacePdbs, + processNamespaceSecret, + ProcessNamespaceServices, + processNamespaceSA, + ProcessNamespaceDeployments, + processNamespacePvcs, + processNamespaceRoles, + ProcessNamespaceStatefulSets, + } + for _, fn := range processFunctions { + usedResources, err := fn(clientset, namespace) + if err != nil { + return nil, err + } + if len(usedResources) > 0 { + return []string{namespace}, nil + } + } + + ns, err := clientset.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + if ns.Labels["kor/used"] == "true" { + return []string{namespace}, nil + } + + return []string{}, nil +} diff --git a/pkg/kor/namespaces_test.go b/pkg/kor/namespaces_test.go new file mode 100644 index 00000000..9b22f230 --- /dev/null +++ b/pkg/kor/namespaces_test.go @@ -0,0 +1,149 @@ +package kor + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" +) + +type initClientFn func(t *testing.T) *fake.Clientset + +func createTestNamespace(t *testing.T) *fake.Clientset { + clientset := fake.NewSimpleClientset() + + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: testNamespace}, + }, metav1.CreateOptions{}) + + if err != nil { + t.Fatalf("Error creating namespace %s: %v", testNamespace, err) + } + + return clientset +} + +func TestRetrieveUsedNS(t *testing.T) { + tests := []struct { + description string + initClient initClientFn + expectUsed bool + }{ + { + description: "unused", + initClient: func(t *testing.T) *fake.Clientset { return fake.NewSimpleClientset() }, + expectUsed: false, + }, + { + description: "used-with-ConfigMap", + initClient: createTestConfigmaps, + expectUsed: true, + }, + { + description: "used-with-HorizontalPodAutoscalers", + initClient: createTestHpas, + expectUsed: true, + }, + { + description: "used-with-Ingress", + initClient: createTestIngresses, + expectUsed: true, + }, + { + description: "used-with-PodDisruptionBudget", + initClient: createTestPdbs, + expectUsed: true, + }, + { + description: "used-with-Secret", + initClient: createTestSecrets, + expectUsed: true, + }, + { + description: "used-with-Service", + initClient: createTestServices, + expectUsed: true, + }, + { + description: "used-with-ServiceAccount", + initClient: createTestServiceAccounts, + expectUsed: true, + }, + { + description: "used-with-Deployment", + initClient: createTestDeployments, + expectUsed: true, + }, + { + description: "used-with-PersistentVolumeClaim", + initClient: createTestPvcs, + expectUsed: true, + }, + { + description: "used-with-Role", + initClient: createTestRoles, + expectUsed: true, + }, + { + description: "used-with-StatefulSet", + initClient: createTestStatefulSets, + expectUsed: true, + }, + } + + for _, test := range tests { + clientset := test.initClient(t) + + usedNS, err := retrieveUsedNS(clientset, testNamespace) + if err != nil { + t.Errorf("test %s failed: %v", test.description, err) + } + if test.expectUsed && len(usedNS) == 0 { + t.Errorf("test %s failed, expected used namespace, got unused", test.description) + } + if !test.expectUsed && len(usedNS) != 0 { + t.Errorf("test %s failed, expected unused namespace, got used", test.description) + } + } +} + +func TestGetUnusedNamespaceStructured(t *testing.T) { + clientset := createTestNamespace(t) + + includeExcludeLists := IncludeExcludeLists{ + IncludeListStr: "", + ExcludeListStr: "", + } + + output, err := GetUnusedNamespacesStructured(includeExcludeLists, clientset, "json") + if err != nil { + t.Fatalf("Error calling GetUnusedNamespacesStructured: %v", err) + } + + expectedOutput := map[string]map[string][]string{ + testNamespace: { + "Namespaces": {testNamespace}, + }, + } + + 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: %v", actualOutput) + } +} + +func init() { + scheme.Scheme = runtime.NewScheme() + _ = appsv1.AddToScheme(scheme.Scheme) +} From ebe1770c83e76e1063c4d9278ec41a57cfe686a3 Mon Sep 17 00:00:00 2001 From: liornoy Date: Fri, 13 Oct 2023 16:26:04 +0300 Subject: [PATCH 2/2] Add namespace command to the README Updates the README to include the new namespace command. Signed-off-by: liornoy --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ed9604c8..3656d178 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - PVCs - Ingresses - PDBs +- Namespaces ![Kor Screenshot](/images/screenshot.png) @@ -72,6 +73,7 @@ Kor provides various subcommands to identify and list unused resources. The avai - `pvc` - Gets unused PVCs for the specified namespace or all namespaces. - `ingress` - Gets unused Ingresses for the specified namespace or all namespaces. - `pdb` - Gets unused PDBs for the specified namespace or all namespaces. +- `namespace` - Gets unused Namespaces for the specified namespace or all namespaces. - `exporter` - Export Prometheus metrics. ### Supported Flags