From d5184b33c7a4252928ba2f1e21d2fb24cd219695 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 7 Jul 2024 16:08:55 +0100 Subject: [PATCH 01/17] rebase and fix conflicts --- .gitignore | 5 +- Makefile | 7 + README.md | 3 + cmd/kor/all.go | 2 +- cmd/kor/namespaces.go | 50 ++++ pkg/filters/filters.go | 66 +++++ pkg/filters/filters_test.go | 307 ++++++++++++++++++++++ pkg/filters/options.go | 2 + pkg/kor/delete.go | 45 +++- pkg/kor/kor_test.go | 92 +++++++ pkg/kor/namespaces.go | 236 +++++++++++++++++ pkg/kor/namespacesMoreTests_test.go | 370 ++++++++++++++++++++++++++ pkg/kor/namespaces_test.go | 388 ++++++++++++++++++++++++++++ 13 files changed, 1565 insertions(+), 8 deletions(-) create mode 100644 cmd/kor/namespaces.go create mode 100644 pkg/kor/namespaces.go create mode 100644 pkg/kor/namespacesMoreTests_test.go create mode 100644 pkg/kor/namespaces_test.go diff --git a/.gitignore b/.gitignore index 1a784ad3..03f6f6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ -.vscode *.iml .idea/ +.vscode/ dist/** main.exe -coverage.txt +coverage.* build/ kor !kor/ *.swp hack/exceptions +.envrc diff --git a/Makefile b/Makefile index 835f8dda..11c2fd6b 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,9 @@ EXCEPTIONS_FILE_PATTERN := *.json build: go build -o build/kor main.go +clean: + rm -fr build coverage.txt coverage.html + lint: golangci-lint run @@ -15,6 +18,10 @@ lint-fix: test: go test -race -coverprofile=coverage.txt -shuffle on ./... +cover: test + go tool cover -func=coverage.txt + go tool cover -o coverage.html -html=coverage.txt + sort-exception-files: @echo "Sorting exception files..." @find $(EXCEPTIONS_DIR) -name '$(EXCEPTIONS_FILE_PATTERN)' | xargs -I{} -P 4 sh -c ' \ diff --git a/README.md b/README.md index 38f27ba3..dacd481b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - StorageClasses - NetworkPolicies - RoleBindings +- Namespaces ![Kor Screenshot](/images/show_reason_screenshot.png) @@ -133,6 +134,7 @@ Kor provides various subcommands to identify and list unused resources. The avai - `daemonset`- Gets unused DaemonSets for the specified namespace or all namespaces. - `finalizer` - Gets unused pending deletion resources for the specified namespace or all namespaces. - `networkpolicy` - Gets unused NetworkPolicies for the specified namespace or all namespaces. +- `namespace` - Gets unused Namespaces for the specified namespace or all namespaces. - `exporter` - Export Prometheus metrics. - `version` - Print kor version information. @@ -194,6 +196,7 @@ kor [subcommand] --help | DaemonSets | DaemonSets not scheduled on any nodes | | StorageClasses | StorageClasses not used by any PVs / PVCs | | NetworkPolicies | NetworkPolicies with no Pods selected by podSelector or Ingress / Egress rules | +| Namespaces | Only empty namespaces | ### Deleting Unused resources diff --git a/cmd/kor/all.go b/cmd/kor/all.go index 3478c9be..3a6c6fcc 100644 --- a/cmd/kor/all.go +++ b/cmd/kor/all.go @@ -11,7 +11,7 @@ import ( var allCmd = &cobra.Command{ Use: "all", - Short: "Gets unused resources", + Short: "Gets unused namespaced resources", Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { clientset := kor.GetKubeClient(kubeconfig) diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go new file mode 100644 index 00000000..32fcc94a --- /dev/null +++ b/cmd/kor/namespaces.go @@ -0,0 +1,50 @@ +package kor + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/yonahd/kor/pkg/kor" + "github.com/yonahd/kor/pkg/utils" +) + +var namespaceCmd = &cobra.Command{ + Use: "namespace", + Aliases: []string{"ns", "namespaces"}, + Short: "Gets unused namespaces", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + clientset := kor.GetKubeClient(kubeconfig) + dynamicClient := kor.GetDynamicClient(kubeconfig) + + if response, err := kor.GetUnusedNamespaces( + ctx, + filterOptions, + clientset, + dynamicClient, + outputFormat, + opts, + ); err != nil { + fmt.Println(err) + } else { + utils.PrintLogo(outputFormat) + fmt.Println(response) + } + }, +} + +func init() { + namespaceCmd.PersistentFlags().StringSliceVarP( + &filterOptions.IgnoreResourceTypes, + "ignore-resource-types", + "i", + filterOptions.IgnoreResourceTypes, + "Child resource type selector to filter out from namespace emptiness evaluation,"+ + " example: --ignore-resource-types secrets,configmaps."+ + " Types should be specified in a format printed out in NAME column by 'kubectl api-resources --namespaced=true'.", + ) + rootCmd.AddCommand(namespaceCmd) +} diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go index f310e61e..41c32824 100644 --- a/pkg/filters/filters.go +++ b/pkg/filters/filters.go @@ -15,6 +15,26 @@ const ( KorLabelFilterName = "korlabel" ) +var ( + SystemNamespaceNames = []string{"default", "kube-system", "kube-public", "kube-node-lease"} +) + +type FilterFunction func(object runtime.Object, opts *Options) bool + +// ApplyFilters is a function to apply a list of FilterFunctions to a given object +func ApplyFilters( + object runtime.Object, + opts *Options, + funcsToCall ...FilterFunction, +) bool { + for _, fn := range funcsToCall { + if pass := fn(object, opts); pass { + return true + } + } + return false +} + // KorLabelFilter is a filter that filters out resources that are ["kor/used"] != "true" func KorLabelFilter(object runtime.Object, opts *Options) bool { if meta, ok := object.(metav1.Object); ok { @@ -107,3 +127,49 @@ func HasIncludedAge(creationTime metav1.Time, filterOpts *Options) (bool, error) return true, nil } + +// SystemNamespaceFilter is a filter that filters out namespaces that are created with the cluster by default +func SystemNamespaceFilter(object runtime.Object, filterOpts *Options) bool { + if meta, ok := object.(metav1.Object); ok { + namespaceName := meta.GetName() + for _, systemNamespace := range SystemNamespaceNames { + if namespaceName == systemNamespace { + return true + } + } + } + return false +} + +// ExcludeNamespacesFilter is a filter that filters out namespaces specified by user +func ExcludeNamespacesFilter(object runtime.Object, filterOpts *Options) bool { + if filterOpts.ExcludeNamespaces != nil { + excludeList := filterOpts.ExcludeNamespaces + if meta, ok := object.(metav1.Object); ok { + namespaceName := meta.GetName() + for _, unwantedNamespace := range excludeList { + if namespaceName == unwantedNamespace { + return true + } + } + } + } + return false +} + +// IncludeNamespacesFilter is a filter that acts as a whitelist, only these namespaces will be processed if specified +func IncludeNamespacesFilter(object runtime.Object, filterOpts *Options) bool { + if filterOpts.IncludeNamespaces != nil { + includeList := filterOpts.IncludeNamespaces + if meta, ok := object.(metav1.Object); ok { + namespaceName := meta.GetName() + for _, wantedNamespace := range includeList { + if namespaceName == wantedNamespace { + return false + } + } + } + return true + } + return false +} diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go index 37b495c6..25811094 100644 --- a/pkg/filters/filters_test.go +++ b/pkg/filters/filters_test.go @@ -177,3 +177,310 @@ func TestKorLabelFilter(t *testing.T) { }) } } + +func TestIncludeNamespacesFilter(t *testing.T) { + type args struct { + object runtime.Object + opts *Options + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "only include list provided and match", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: []string{"test-ns1", "test-ns2"}, + ExcludeNamespaces: nil, + }, + }, + want: false, + }, + { + name: "only include list provided and no match", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: []string{"test-ns2", "test-ns3"}, + ExcludeNamespaces: nil, + }, + }, + want: true, + }, + { + name: "include list is nil", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: nil, + ExcludeNamespaces: nil, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IncludeNamespacesFilter(tt.args.object, tt.args.opts); got != tt.want { + t.Errorf("IncludeNamespacesFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExcludeNamespacesFilter(t *testing.T) { + type args struct { + object runtime.Object + opts *Options + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "only exclude list provided and match", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: nil, + ExcludeNamespaces: []string{"test-ns1", "test-ns2"}, + }, + }, + want: true, + }, + { + name: "only exclude list provided and no match", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: nil, + ExcludeNamespaces: []string{"test-ns2", "test-ns3"}, + }, + }, + want: false, + }, + { + name: "exclude list is nil", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{ + IncludeNamespaces: nil, + ExcludeNamespaces: nil, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExcludeNamespacesFilter(tt.args.object, tt.args.opts); got != tt.want { + t.Errorf("ExcludeNamespacesFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSystemNamespaceFilter(t *testing.T) { + type args struct { + object runtime.Object + opts *Options + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "system namespace - default", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + opts: &Options{}, + }, + want: true, + }, + { + name: "system namespace - kube-system", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-system", + }, + }, + opts: &Options{}, + }, + want: true, + }, + { + name: "system namespace - kube-public", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-public", + }, + }, + opts: &Options{}, + }, + want: true, + }, + { + name: "system namespace - kube-node-lease", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-node-lease", + }, + }, + opts: &Options{}, + }, + want: true, + }, + { + name: "non system namespace - test-ns1", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SystemNamespaceFilter(tt.args.object, tt.args.opts); got != tt.want { + t.Errorf("SystemNamespaceFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func testHelperFilterTrue(object runtime.Object, filterOpts *Options) bool { + return true +} + +func testHelperFilterFalse(object runtime.Object, filterOpts *Options) bool { + return false +} + +func TestApplyFilters(t *testing.T) { + type args struct { + object runtime.Object + opts *Options + funcs []FilterFunction + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "false,false,false functions", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{}, + funcs: []FilterFunction{ + testHelperFilterFalse, + testHelperFilterFalse, + testHelperFilterFalse, + }, + }, + want: false, + }, + { + name: "true,false,true functions", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{}, + funcs: []FilterFunction{ + testHelperFilterTrue, + testHelperFilterFalse, + testHelperFilterTrue, + }, + }, + want: true, + }, + { + name: "false,false,true functions", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{}, + funcs: []FilterFunction{ + testHelperFilterFalse, + testHelperFilterFalse, + testHelperFilterTrue, + }, + }, + want: true, + }, + { + name: "true,true,true functions", + args: args{ + object: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ns1", + }, + }, + opts: &Options{}, + funcs: []FilterFunction{ + testHelperFilterTrue, + testHelperFilterTrue, + testHelperFilterTrue, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ApplyFilters(tt.args.object, tt.args.opts, tt.args.funcs...); got != tt.want { + t.Errorf("ApplyFilters() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/filters/options.go b/pkg/filters/options.go index 1ec13a00..6ad3120d 100644 --- a/pkg/filters/options.go +++ b/pkg/filters/options.go @@ -39,6 +39,8 @@ type Options struct { ExcludeNamespaces []string // IncludeNamespaces is a namespace selector to include resources in matching namespaces IncludeNamespaces []string + // IgnoreResourceTypes is a namespace selector to exclude specified resource type evaluation, only applicable to namespaces + IgnoreResourceTypes []string namespace []string once sync.Once diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go index 7efcec3e..3b29deca 100644 --- a/pkg/kor/delete.go +++ b/pkg/kor/delete.go @@ -84,6 +84,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa "RoleBinding": func(clientset kubernetes.Interface, namespace, name string) error { return clientset.RbacV1().RoleBindings(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) }, + "Namespace": func(clientset kubernetes.Interface, namespace, name string) error { + return clientset.CoreV1().Namespaces().Delete(context.TODO(), name, metav1.DeleteOptions{}) + }, } return deleteResourceApiMap @@ -277,6 +280,13 @@ func DeleteResourceWithFinalizer(resources []ResourceInfo, dynamicClient dynamic return remainingResources, nil } +func namespacedMessageSuffix(namespace string) string { + if namespace != "" { + return " in namespace " + namespace + } + return "" +} + func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespace, resourceType string, noInteractive bool) ([]ResourceInfo, error) { deletedDiff := []ResourceInfo{} @@ -288,7 +298,12 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa } if !noInteractive { - fmt.Printf("Do you want to delete %s %s in namespace %s? (Y/N): ", resourceType, resource.Name, namespace) + fmt.Printf( + "Do you want to delete %s %s%s? (Y/N): ", + resourceType, + resource.Name, + namespacedMessageSuffix(namespace), + ) var confirmation string _, err := fmt.Scanf("%s\n", &confirmation) if err != nil { @@ -299,7 +314,12 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa if strings.ToLower(confirmation) != "y" && strings.ToLower(confirmation) != "yes" { deletedDiff = append(deletedDiff, resource) - fmt.Printf("Do you want flag the resource %s %s in namespace %s as In Use? (Y/N): ", resourceType, resource.Name, namespace) + fmt.Printf( + "Do you want flag the resource %s %s%s as In Use? (Y/N): ", + resourceType, + resource.Name, + namespacedMessageSuffix(namespace), + ) var inUse string _, err := fmt.Scanf("%s\n", &inUse) if err != nil { @@ -309,7 +329,14 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa if strings.ToLower(inUse) == "y" || strings.ToLower(inUse) == "yes" { if err := FlagResource(clientset, namespace, resourceType, resource.Name); err != nil { - fmt.Fprintf(os.Stderr, "Failed to flag resource %s %s in namespace %s as In Use: %v\n", resourceType, resource.Name, namespace, err) + fmt.Fprintf( + os.Stderr, + "Failed to flag resource %s %s%s as In Use: %v\n", + resourceType, + resource.Name, + namespacedMessageSuffix(namespace), + err, + ) } continue } @@ -317,9 +344,17 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa } } - fmt.Printf("Deleting %s %s in namespace %s\n", resourceType, resource.Name, namespace) + fmt.Printf("Deleting %s %s%s\n", resourceType, resource.Name, namespacedMessageSuffix(namespace)) + if err := deleteFunc(clientset, namespace, resource.Name); err != nil { - fmt.Fprintf(os.Stderr, "Failed to delete %s %s in namespace %s: %v\n", resourceType, resource.Name, namespace, err) + fmt.Fprintf( + os.Stderr, + "Failed to delete %s %s%s: %v\n", + resourceType, + resource.Name, + namespacedMessageSuffix(namespace), + err, + ) continue } deletedResource := resource diff --git a/pkg/kor/kor_test.go b/pkg/kor/kor_test.go index eafddb17..92d01c21 100644 --- a/pkg/kor/kor_test.go +++ b/pkg/kor/kor_test.go @@ -198,3 +198,95 @@ func TestResourceExceptionWithRegexPrefixInNamespace(t *testing.T) { t.Error("Expected to find exception") } } + +func TestNamespacedMessageSuffix(t *testing.T) { + type args struct { + namespace string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty string passed", + args: args{ + namespace: "", + }, + want: "", + }, + { + name: "namespace name passed", + args: args{ + namespace: "test-ns1", + }, + want: " in namespace test-ns1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := namespacedMessageSuffix(tt.args.namespace); got != tt.want { + t.Errorf( + "namespacedMessageSuffix() = '%v', want '%v'", + got, + tt.want, + ) + } + }) + } +} + +// func TestFormatOutput(t *testing.T) { +// type args struct { +// namespace string +// resources []string +// verbose bool +// } +// tests := []struct { +// name string +// args args +// want string +// }{ +// { +// name: "verbose, empty namespace, empty resource list", +// args: args{ +// namespace: "", +// resources: []string{}, +// verbose: true, +// }, +// want: "No unused TestType found\n", +// }, +// { +// name: "verbose, non empty namespace, empty resource list", +// args: args{ +// namespace: "test-ns", +// resources: []string{}, +// verbose: true, +// }, +// want: "No unused TestType found in namespace test-ns\n", +// }, +// { +// name: "non verbose, empty namespace, empty resource list", +// args: args{ +// namespace: "", +// resources: []string{}, +// verbose: false, +// }, +// want: "", +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// if got := FormatOutput( +// tt.args.namespace, +// tt.args.resources, +// "TestType", +// Opts{Verbose: tt.args.verbose}, +// ); got != tt.want { +// t.Errorf("FormatOutput() = '%v', want '%v'", got, tt.want) +// } +// }) +// } + +// } diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go new file mode 100644 index 00000000..76922a27 --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,236 @@ +package kor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + + "github.com/yonahd/kor/pkg/filters" +) + +type GenericResource struct { + NamespacedName types.NamespacedName + GVR schema.GroupVersionResource +} + +func getGVR(name string, splitGV []string) (*schema.GroupVersionResource, error) { + switch NumberOfGVPartsFound := len(splitGV); NumberOfGVPartsFound { + case 1: + return &schema.GroupVersionResource{ + Version: splitGV[0], + Resource: name, + }, nil + case 2: + return &schema.GroupVersionResource{ + Group: splitGV[0], + Version: splitGV[1], + Resource: name, + }, nil + default: + return nil, fmt.Errorf("gv is wrong length slice: %d", NumberOfGVPartsFound) + } +} + +func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { + for _, ignoreType := range ignoreResourceTypes { + if resource == ignoreType { + return true + } + } + return false +} + +func ignorePredefinedResource(gr GenericResource) bool { + // Specific list of resources to ignore - resources created in all namespaced by default + if gr.GVR.Resource == "configmaps" && gr.GVR.Version == "v1" && gr.NamespacedName.Name == "kube-root-ca.crt" { + return true + } + if gr.GVR.Resource == "serviceaccounts" && gr.GVR.Version == "v1" && gr.NamespacedName.Name == "default" { + return true + } + if gr.GVR.Resource == "events" { + return true + } + return false +} + +func isNamespaceNotEmpty( + gvr *schema.GroupVersionResource, + unstructuredList *unstructured.UnstructuredList, + filterOpts *filters.Options, +) bool { + for _, unstructuredObj := range unstructuredList.Items { + gr := GenericResource{ + GVR: *gvr, + NamespacedName: types.NamespacedName{ + Namespace: unstructuredObj.GetNamespace(), + Name: unstructuredObj.GetName(), + }, + } + // Ignore default cluster resources + if ignorePredefinedResource(gr) { + continue + } + // User specified resource type ignore list + if ignoreResourceType(gr.GVR.Resource, filterOpts.IgnoreResourceTypes) { + continue + } + return true + } + return false +} + +func isErrorOrNamespaceContainsResources( + ctx context.Context, + clientset kubernetes.Interface, + dynamicClient dynamic.Interface, + namespace string, + filterOpts *filters.Options, +) (bool, error) { + apiResourceLists, err := clientset.Discovery().ServerPreferredNamespacedResources() + if err != nil { + return true, err + } + + // Iterate over all API resources and list instances of each in the specified namespace + for _, apiResourceList := range apiResourceLists { + for _, apiResource := range apiResourceList.APIResources { + gv := strings.Split(apiResourceList.GroupVersion, "/") + gvr, err := getGVR(apiResource.Name, gv) + if err != nil { + return true, err + } + + unstructuredList, err := dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + continue + } + + if isNamespaceNotEmpty(gvr, unstructuredList, filterOpts) { + return true, nil + } + } + } + return false, nil +} + +func processNamespaces( + ctx context.Context, + clientset kubernetes.Interface, + dynamicClient dynamic.Interface, + filterOpts *filters.Options, +) ([]ResourceInfo, error) { + var unusedNamespaces []ResourceInfo + + namespaces, err := clientset.CoreV1().Namespaces().List( + context.TODO(), + metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to list namespaces") + } + + for _, namespace := range namespaces.Items { + if pass := filters.ApplyFilters( + &namespace, filterOpts, + filters.SystemNamespaceFilter, + filters.ExcludeNamespacesFilter, + filters.IncludeNamespacesFilter, + filters.KorLabelFilter, + filters.LabelFilter, + filters.AgeFilter, + ); pass { + continue + } + + // skipping default resources here + resourceFound, err := isErrorOrNamespaceContainsResources( + ctx, + clientset, + dynamicClient, + namespace.Name, + filterOpts, + ) + if err != nil { + return unusedNamespaces, err + } + + // construct list of unused namespaces here following a set of rules + if !resourceFound { + unusedNamespaces = append(unusedNamespaces, ResourceInfo{namespace.Name, "unused namespace"}) + } + } + return unusedNamespaces, nil +} + +func GetUnusedNamespaces( + ctx context.Context, + filterOpts *filters.Options, + clientset kubernetes.Interface, + dynamicClient dynamic.Interface, + outputFormat string, + opts Opts, +) (string, error) { + resources := make(map[string]map[string][]ResourceInfo) + + if len(filterOpts.IncludeNamespaces) > 0 && len(filterOpts.ExcludeNamespaces) > 0 { + fmt.Fprintf(os.Stderr, "Exclude namespaces can't be used together with include namespaces. Ignoring --exclude-namespace(-e) flag\n") + filterOpts.ExcludeNamespaces = nil + } + + diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) + } + + if len(diff) > 0 { + // We consider cluster scope resources in "" (empty string) namespace, as it is common in k8s + if resources[""] == nil { + resources[""] = make(map[string][]ResourceInfo) + } + resources[""]["Namespaces"] = diff + } + + if opts.DeleteFlag { + if diff, err = DeleteResource( + diff, + clientset, + "", + "Namespace", + opts.NoInteractive, + ); err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete namespace %s : %v\n", diff, err) + } + } + + var outputBuffer bytes.Buffer + var jsonResponse []byte + switch outputFormat { + case "table": + outputBuffer = FormatOutput(resources, opts) + case "json", "yaml": + var err error + if jsonResponse, err = json.MarshalIndent(resources, "", " "); err != nil { + return "", err + } + } + + unusedNamespaces, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) + if err != nil { + fmt.Printf("err: %v\n", err) + } + + return unusedNamespaces, nil +} diff --git a/pkg/kor/namespacesMoreTests_test.go b/pkg/kor/namespacesMoreTests_test.go new file mode 100644 index 00000000..9d579601 --- /dev/null +++ b/pkg/kor/namespacesMoreTests_test.go @@ -0,0 +1,370 @@ +package kor + +import ( + "context" + "fmt" + "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/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + discoveryfake "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + + "github.com/yonahd/kor/pkg/filters" +) + +type fakeHappyDiscovery struct { + discoveryfake.FakeDiscovery +} + +func (c *fakeHappyDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return []*metav1.APIResourceList{ + { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Namespaced: true, + Kind: "Deployment", + }, + }, + }, + }, nil +} + +type fakeUnhappyDiscovery struct { + discoveryfake.FakeDiscovery +} + +func (c *fakeUnhappyDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, fmt.Errorf("fake error from discovery") +} + +type fakeBrokenAPIResourceListDiscovery struct { + discoveryfake.FakeDiscovery +} + +func (c *fakeBrokenAPIResourceListDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return []*metav1.APIResourceList{ + { + GroupVersion: "fake/broken/apps/v1", // this line causes error + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Namespaced: true, + Kind: "Deployment", + }, + }, + }, + }, nil +} + +type fakeClientset struct { + kubernetes.Interface + discovery discovery.DiscoveryInterface +} + +func (c *fakeClientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +// Create a test deployment in the namespace +func defineDeployObject(ns, name string) *appsv1.Deployment { + var replicas int32 = 42 + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: map[string]string{ + "app": "test", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "test", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "test", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Image: "nginx", + }, + }, + }, + }, + }, + } +} + +func defineNamespaceObject(nsName string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + }, + } +} + +func getNamespaceTestSchema(t *testing.T) *runtime.Scheme { + scheme := runtime.NewScheme() + err := corev1.AddToScheme(scheme) + if err != nil { + t.Errorf("Failed to add corev1 to scheme: %v", err) + } + err = appsv1.AddToScheme(scheme) + if err != nil { + t.Errorf("Failed to add appsv1 to scheme: %v", err) + } + return scheme + +} + +func createHappyDeployFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + + scheme := getNamespaceTestSchema(t) + namespace := defineNamespaceObject(ns) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + + deployment := defineDeployObject(ns, name) + _, err = clientset.AppsV1().Deployments("test-namespace").Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test deployment: %v", err) + } + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, deployment, namespace) + + return clientset, dynamicClient +} + +func createHappyEmptyFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + + scheme := getNamespaceTestSchema(t) + namespace := defineNamespaceObject(ns) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, namespace) + + return clientset, dynamicClient +} + +func createUnhappyDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + fakeDisc := &fakeUnhappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + + scheme := getNamespaceTestSchema(t) + namespace := defineNamespaceObject(ns) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, namespace) + + return clientset, dynamicClient +} + +func createBrokenAPIResourceListDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + fakeDisc := &fakeBrokenAPIResourceListDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + + scheme := getNamespaceTestSchema(t) + namespace := defineNamespaceObject(ns) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, namespace) + + return clientset, dynamicClient +} + +func createDynamicDeployListForcedErrorFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + + scheme := getNamespaceTestSchema(t) + namespace := defineNamespaceObject(ns) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + + deployment := defineDeployObject(ns, name) + _, err = clientset.AppsV1().Deployments("test-namespace").Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test deployment: %v", err) + } + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds) + dynamicClient.PrependReactor("list", "deployments", func(action ktesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("forced error") + }) + + return clientset, dynamicClient +} + +type GetFakeClientInterfacesFunc func(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) + +func Test_namespaces_IsErrorOrNamespaceContainsResources(t *testing.T) { + tests := []struct { + name string + + objName string + namespaceName string + ctx context.Context + getClientsFunc GetFakeClientInterfacesFunc + filterOpts *filters.Options + + expectedReturn bool + expectedError bool + }{ + { + name: "deployment exists, no errors, ignoring secrets and configmaps", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createHappyDeployFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"configmaps", "secrets"}, + }, + + expectedReturn: true, + expectedError: false, + }, + { + name: "deployment exists, no errors, ignoring deployments", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createHappyDeployFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"deployments"}, + }, + + expectedReturn: false, + expectedError: false, + }, + { + name: "deployment list is empty, no errors, ignoring secrets", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createHappyEmptyFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: false, + expectedError: false, + }, + { + name: "deployment list is empty, error in discovery, ignoring secrets", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createUnhappyDiscoveryFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: true, + expectedError: true, + }, + { + name: "imitate broken APIResourceList, error in discovery, ignoring secrets", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createBrokenAPIResourceListDiscoveryFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: true, + expectedError: true, + }, + { + name: "imitate failed list deployments call, error in dynamic client, ignoring secrets", + + objName: "test-object", + namespaceName: "test-namespace", + ctx: context.TODO(), + getClientsFunc: createDynamicDeployListForcedErrorFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: false, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientset, dynamicClient := tt.getClientsFunc(tt.ctx, t, tt.namespaceName, tt.objName) + got, err := isErrorOrNamespaceContainsResources(tt.ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) + if (err != nil) != tt.expectedError { + t.Errorf("isErrorOrNamespaceContainsResources() = expected error: %t, got: '%v'", tt.expectedError, err) + } + if got != tt.expectedReturn { + t.Errorf("isErrorOrNamespaceContainsResources() = got %t, want %t", got, tt.expectedReturn) + } + }) + } +} diff --git a/pkg/kor/namespaces_test.go b/pkg/kor/namespaces_test.go new file mode 100644 index 00000000..dc4f2ff4 --- /dev/null +++ b/pkg/kor/namespaces_test.go @@ -0,0 +1,388 @@ +package kor + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + + "github.com/yonahd/kor/pkg/filters" +) + +func Test_namespaces_IgnoreResourceType(t *testing.T) { + type args struct { + resource string + ignoreResources []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "non matching resource", + args: args{ + resource: "pods", + ignoreResources: []string{ + "configmaps", + "secrets", + }, + }, + want: false, + }, + { + name: "matching resource", + args: args{ + resource: "secrets", + ignoreResources: []string{ + "configmaps", + "secrets", + }, + }, + want: true, + }, + { + name: "empty resource ignore list", + args: args{ + resource: "secrets", + ignoreResources: []string{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ignoreResourceType(tt.args.resource, tt.args.ignoreResources); got != tt.want { + t.Errorf("ignoreResourceType() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_namespaces_GetGVR(t *testing.T) { + type args struct { + name string + splitGV []string + } + tests := []struct { + name string + args args + want *schema.GroupVersionResource + expectErr bool + }{ + { + name: "number of parts 0 - expect error", + args: args{ + name: "deployments", + splitGV: []string{}, + }, + want: nil, + expectErr: true, + }, + { + name: "number of parts 1", + args: args{ + name: "secrets", + splitGV: []string{ + "v1", + }, + }, + want: &schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "secrets", + }, + expectErr: false, + }, + { + name: "number of parts 2", + args: args{ + name: "deployments", + splitGV: []string{ + "apps", + "v1", + }, + }, + want: &schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + }, + expectErr: false, + }, + { + name: "number of parts 4 - expect error", + args: args{ + name: "deployments", + splitGV: []string{ + "apps", + "v1", + "test-deploy01", + }, + }, + want: nil, + expectErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getGVR(tt.args.name, tt.args.splitGV) + if (err != nil) != tt.expectErr { + t.Errorf("getGVR() = expected error: %t, got: '%v'", tt.expectErr, err) + } + if got != nil && *got != *tt.want { + t.Errorf("getGVR() = %+v, want %+v", got, tt.want) + } + }) + } +} +func Test_namespaces_IgnorePredefinedResource(t *testing.T) { + tests := []struct { + name string + gr GenericResource + expectedReturn bool + }{ + { + name: "configmap kube-root-ca.crt in default", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "kube-root-ca.crt", + Namespace: "default", + }, + GVR: schema.GroupVersionResource{ + Resource: "configmaps", + Version: "v1", + }, + }, + expectedReturn: true, + }, + { + name: "configmap kube-root-ca.crt in abc", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "kube-root-ca.crt", + Namespace: "abc", + }, + GVR: schema.GroupVersionResource{ + Resource: "configmaps", + Version: "v1", + }, + }, + expectedReturn: true, + }, + { + name: "sa default in default", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "default", + Namespace: "default", + }, + GVR: schema.GroupVersionResource{ + Resource: "serviceaccounts", + Version: "v1", + }, + }, + expectedReturn: true, + }, + { + name: "sa default in cde", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "default", + Namespace: "cde", + }, + GVR: schema.GroupVersionResource{ + Resource: "serviceaccounts", + Version: "v1", + }, + }, + expectedReturn: true, + }, + { + name: "event in default", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "test-event", + Namespace: "default", + }, + GVR: schema.GroupVersionResource{ + Resource: "events", + }, + }, + expectedReturn: true, + }, + { + name: "event in qqq", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "test-event", + Namespace: "qqq", + }, + GVR: schema.GroupVersionResource{ + Resource: "events", + }, + }, + expectedReturn: true, + }, + { + name: "test-configmap in default", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "test-configmap", + Namespace: "default", + }, + GVR: schema.GroupVersionResource{ + Resource: "configmaps", + Version: "v1", + }, + }, + expectedReturn: false, + }, + { + name: "test-serviceaccount in default", + gr: GenericResource{ + NamespacedName: types.NamespacedName{ + Name: "test-serviceaccount", + Namespace: "default", + }, + GVR: schema.GroupVersionResource{ + Resource: "serviceaccounts", + Version: "v1", + }, + }, + expectedReturn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ignorePredefinedResource(tt.gr) + if got != tt.expectedReturn { + t.Errorf("ignorePredefinedResource() = %t, want %t", got, tt.expectedReturn) + } + }) + } +} + +func Test_namespaces_IsNamespaceNotEmpty(t *testing.T) { + tests := []struct { + name string + gvr *schema.GroupVersionResource + objects *unstructured.UnstructuredList + filterOpts *filters.Options + expectedReturn bool + }{ + { + name: "deployment exists, ignoring secrets and configmaps", + gvr: &schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + }, + objects: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "default", + }, + }, + }, + }, + }, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"configmaps", "secrets"}, + }, + expectedReturn: true, + }, + { + name: "deployment exists, ignoring deployments", + gvr: &schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + }, + objects: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "default", + }, + }, + }, + }, + }, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"deployments"}, + }, + expectedReturn: false, + }, + { + name: "event exists but ignored, ignoring deployments", + gvr: &schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "events", + }, + objects: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Event", + "metadata": map[string]interface{}{ + "name": "pod-event", + "namespace": "abc", + }, + }, + }, + }, + }, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"deployments"}, + }, + expectedReturn: false, + }, + { + name: "default sa exists but ignored", + gvr: &schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "serviceaccounts", + }, + objects: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": map[string]interface{}{ + "name": "default", + "namespace": "cde", + }, + }, + }, + }, + }, + filterOpts: &filters.Options{}, + expectedReturn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isNamespaceNotEmpty(tt.gvr, tt.objects, tt.filterOpts) + if got != tt.expectedReturn { + t.Errorf("Expected namespace to be not empty (%t), but result is %t", tt.expectedReturn, got) + } + }) + } +} From 904c6b9111b07efee3c1ee103d8793644977bd61 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 7 Jul 2024 16:17:45 +0100 Subject: [PATCH 02/17] Fix typo --- pkg/kor/kor_test.go | 55 --------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/pkg/kor/kor_test.go b/pkg/kor/kor_test.go index 92d01c21..9ba080f4 100644 --- a/pkg/kor/kor_test.go +++ b/pkg/kor/kor_test.go @@ -235,58 +235,3 @@ func TestNamespacedMessageSuffix(t *testing.T) { }) } } - -// func TestFormatOutput(t *testing.T) { -// type args struct { -// namespace string -// resources []string -// verbose bool -// } -// tests := []struct { -// name string -// args args -// want string -// }{ -// { -// name: "verbose, empty namespace, empty resource list", -// args: args{ -// namespace: "", -// resources: []string{}, -// verbose: true, -// }, -// want: "No unused TestType found\n", -// }, -// { -// name: "verbose, non empty namespace, empty resource list", -// args: args{ -// namespace: "test-ns", -// resources: []string{}, -// verbose: true, -// }, -// want: "No unused TestType found in namespace test-ns\n", -// }, -// { -// name: "non verbose, empty namespace, empty resource list", -// args: args{ -// namespace: "", -// resources: []string{}, -// verbose: false, -// }, -// want: "", -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// if got := FormatOutput( -// tt.args.namespace, -// tt.args.resources, -// "TestType", -// Opts{Verbose: tt.args.verbose}, -// ); got != tt.want { -// t.Errorf("FormatOutput() = '%v', want '%v'", got, tt.want) -// } -// }) -// } - -// } From fa6a863700acde8b3542b086a3d8c1c781db4028 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Tue, 26 Nov 2024 12:19:30 +0000 Subject: [PATCH 03/17] Fix typo --- pkg/kor/namespaces.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 76922a27..ae685af3 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -17,6 +17,7 @@ import ( "k8s.io/client-go/kubernetes" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" ) @@ -181,7 +182,7 @@ func GetUnusedNamespaces( clientset kubernetes.Interface, dynamicClient dynamic.Interface, outputFormat string, - opts Opts, + opts common.Opts, ) (string, error) { resources := make(map[string]map[string][]ResourceInfo) From f9733ef7b8c55492af6360333b2f19b8e980df29 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Tue, 26 Nov 2024 12:36:32 +0000 Subject: [PATCH 04/17] Fix typo --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index f9d9b110..666fc520 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.2 require ( github.com/fatih/color v1.18.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.9.0 k8s.io/api v0.32.2 @@ -42,6 +43,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/gomega v1.33.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect From 1f9586b65d573a04a2b8076f6225cb3083e0681c Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Thu, 23 Jan 2025 21:36:30 +0000 Subject: [PATCH 05/17] Fix typo --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 666fc520..e57bf9d2 100644 --- a/go.mod +++ b/go.mod @@ -43,8 +43,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/gomega v1.33.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect From 568d14d90f48d865769ff78ac21b0c18504e7b10 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 9 Feb 2025 20:36:22 +0000 Subject: [PATCH 06/17] refactor filtering --- cmd/kor/namespaces.go | 9 +- pkg/kor/exceptions/namespaces/namespaces.json | 20 +++ pkg/kor/kor.go | 1 + pkg/kor/namespaces.go | 135 ++++++++++-------- 4 files changed, 94 insertions(+), 71 deletions(-) create mode 100644 pkg/kor/exceptions/namespaces/namespaces.json diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go index 32fcc94a..fec39ec3 100644 --- a/cmd/kor/namespaces.go +++ b/cmd/kor/namespaces.go @@ -20,14 +20,7 @@ var namespaceCmd = &cobra.Command{ clientset := kor.GetKubeClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) - if response, err := kor.GetUnusedNamespaces( - ctx, - filterOptions, - clientset, - dynamicClient, - outputFormat, - opts, - ); err != nil { + if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil { fmt.Println(err) } else { utils.PrintLogo(outputFormat) diff --git a/pkg/kor/exceptions/namespaces/namespaces.json b/pkg/kor/exceptions/namespaces/namespaces.json new file mode 100644 index 00000000..1a7d9666 --- /dev/null +++ b/pkg/kor/exceptions/namespaces/namespaces.json @@ -0,0 +1,20 @@ +{ + "exceptionNamespaces": [ + { + "Namespace": "default", + "ResourceName": "" + }, + { + "Namespace": "kube-system", + "ResourceName": "" + }, + { + "Namespace": "kube-public", + "ResourceName": "" + }, + { + "Namespace": "kube-node-lease", + "ResourceName": "" + } + ] +} diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go index be6968c0..f93f513a 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -39,6 +39,7 @@ type Config struct { ExceptionJobs []ExceptionResource `json:"exceptionJobs"` ExceptionPdbs []ExceptionResource `json:"exceptionPdbs"` ExceptionRoleBindings []ExceptionResource `json:"exceptionRoleBindings"` + ExceptionNamespaces []ExceptionResource `json:"exceptionNamespaces"` // Add other configurations if needed } diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index ae685af3..c6f34071 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -3,12 +3,12 @@ package kor import ( "bytes" "context" + _ "embed" "encoding/json" "fmt" "os" "strings" - "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -21,11 +21,80 @@ import ( "github.com/yonahd/kor/pkg/filters" ) +//go:embed exceptions/namespaces/namespaces.json +var namespacesConfig []byte + type GenericResource struct { NamespacedName types.NamespacedName GVR schema.GroupVersionResource } +func processNamespaces( + ctx context.Context, + clientset kubernetes.Interface, + dynamicClient dynamic.Interface, + filterOpts *filters.Options, +) ([]ResourceInfo, error) { + var unusedNamespaces []ResourceInfo + + filteredNamespaceNames := filterOpts.Namespaces(clientset) + + config, err := unmarshalConfig(namespacesConfig) + if err != nil { + return nil, err + } + + for _, namespaceName := range filteredNamespaceNames { + namespace, err := clientset.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + if pass, _ := filter.SetObject(namespace).Run(filterOpts); pass { + continue + } + + // ignore namespaces within exception list + exceptionFound, err := isResourceException(namespace.Name, "", config.ExceptionNamespaces) + if err != nil { + return nil, err + } + if exceptionFound { + continue + } + + // skipping default resources here + resourceFound, err := isErrorOrNamespaceContainsResources( + ctx, + clientset, + dynamicClient, + namespaceName, + filterOpts, + ) + if err != nil { + return unusedNamespaces, err + } + + if namespace.Labels["kor/used"] == "false" { + unusedNamespaces = append( + unusedNamespaces, + ResourceInfo{Name: namespace.Name, Reason: "Marked with unused label"}, + ) + continue + } + + // construct list of unused namespaces here following a set of rules + if !resourceFound { + unusedNamespaces = append( + unusedNamespaces, + ResourceInfo{Name: namespace.Name, Reason: "Empty namespace"}, + ) + } + } + + return unusedNamespaces, nil +} + func getGVR(name string, splitGV []string) (*schema.GroupVersionResource, error) { switch NumberOfGVPartsFound := len(splitGV); NumberOfGVPartsFound { case 1: @@ -53,6 +122,7 @@ func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { return false } +// TODO: refactor using exception list func ignorePredefinedResource(gr GenericResource) bool { // Specific list of resources to ignore - resources created in all namespaced by default if gr.GVR.Resource == "configmaps" && gr.GVR.Version == "v1" && gr.NamespacedName.Name == "kube-root-ca.crt" { @@ -127,55 +197,6 @@ func isErrorOrNamespaceContainsResources( return false, nil } -func processNamespaces( - ctx context.Context, - clientset kubernetes.Interface, - dynamicClient dynamic.Interface, - filterOpts *filters.Options, -) ([]ResourceInfo, error) { - var unusedNamespaces []ResourceInfo - - namespaces, err := clientset.CoreV1().Namespaces().List( - context.TODO(), - metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}, - ) - if err != nil { - return nil, errors.Wrap(err, "failed to list namespaces") - } - - for _, namespace := range namespaces.Items { - if pass := filters.ApplyFilters( - &namespace, filterOpts, - filters.SystemNamespaceFilter, - filters.ExcludeNamespacesFilter, - filters.IncludeNamespacesFilter, - filters.KorLabelFilter, - filters.LabelFilter, - filters.AgeFilter, - ); pass { - continue - } - - // skipping default resources here - resourceFound, err := isErrorOrNamespaceContainsResources( - ctx, - clientset, - dynamicClient, - namespace.Name, - filterOpts, - ) - if err != nil { - return unusedNamespaces, err - } - - // construct list of unused namespaces here following a set of rules - if !resourceFound { - unusedNamespaces = append(unusedNamespaces, ResourceInfo{namespace.Name, "unused namespace"}) - } - } - return unusedNamespaces, nil -} - func GetUnusedNamespaces( ctx context.Context, filterOpts *filters.Options, @@ -185,12 +206,6 @@ func GetUnusedNamespaces( opts common.Opts, ) (string, error) { resources := make(map[string]map[string][]ResourceInfo) - - if len(filterOpts.IncludeNamespaces) > 0 && len(filterOpts.ExcludeNamespaces) > 0 { - fmt.Fprintf(os.Stderr, "Exclude namespaces can't be used together with include namespaces. Ignoring --exclude-namespace(-e) flag\n") - filterOpts.ExcludeNamespaces = nil - } - diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) @@ -205,13 +220,7 @@ func GetUnusedNamespaces( } if opts.DeleteFlag { - if diff, err = DeleteResource( - diff, - clientset, - "", - "Namespace", - opts.NoInteractive, - ); err != nil { + if diff, err = DeleteResource(diff, clientset, "", "Namespace", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete namespace %s : %v\n", diff, err) } } From ab1b273a05bd0a391198953aa24589226173ab85 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 10 Feb 2025 07:28:20 +0000 Subject: [PATCH 07/17] remove custom filters --- pkg/filters/filters.go | 66 -------- pkg/filters/filters_test.go | 306 ------------------------------------ 2 files changed, 372 deletions(-) diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go index 41c32824..f310e61e 100644 --- a/pkg/filters/filters.go +++ b/pkg/filters/filters.go @@ -15,26 +15,6 @@ const ( KorLabelFilterName = "korlabel" ) -var ( - SystemNamespaceNames = []string{"default", "kube-system", "kube-public", "kube-node-lease"} -) - -type FilterFunction func(object runtime.Object, opts *Options) bool - -// ApplyFilters is a function to apply a list of FilterFunctions to a given object -func ApplyFilters( - object runtime.Object, - opts *Options, - funcsToCall ...FilterFunction, -) bool { - for _, fn := range funcsToCall { - if pass := fn(object, opts); pass { - return true - } - } - return false -} - // KorLabelFilter is a filter that filters out resources that are ["kor/used"] != "true" func KorLabelFilter(object runtime.Object, opts *Options) bool { if meta, ok := object.(metav1.Object); ok { @@ -127,49 +107,3 @@ func HasIncludedAge(creationTime metav1.Time, filterOpts *Options) (bool, error) return true, nil } - -// SystemNamespaceFilter is a filter that filters out namespaces that are created with the cluster by default -func SystemNamespaceFilter(object runtime.Object, filterOpts *Options) bool { - if meta, ok := object.(metav1.Object); ok { - namespaceName := meta.GetName() - for _, systemNamespace := range SystemNamespaceNames { - if namespaceName == systemNamespace { - return true - } - } - } - return false -} - -// ExcludeNamespacesFilter is a filter that filters out namespaces specified by user -func ExcludeNamespacesFilter(object runtime.Object, filterOpts *Options) bool { - if filterOpts.ExcludeNamespaces != nil { - excludeList := filterOpts.ExcludeNamespaces - if meta, ok := object.(metav1.Object); ok { - namespaceName := meta.GetName() - for _, unwantedNamespace := range excludeList { - if namespaceName == unwantedNamespace { - return true - } - } - } - } - return false -} - -// IncludeNamespacesFilter is a filter that acts as a whitelist, only these namespaces will be processed if specified -func IncludeNamespacesFilter(object runtime.Object, filterOpts *Options) bool { - if filterOpts.IncludeNamespaces != nil { - includeList := filterOpts.IncludeNamespaces - if meta, ok := object.(metav1.Object); ok { - namespaceName := meta.GetName() - for _, wantedNamespace := range includeList { - if namespaceName == wantedNamespace { - return false - } - } - } - return true - } - return false -} diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go index 25811094..6e241e63 100644 --- a/pkg/filters/filters_test.go +++ b/pkg/filters/filters_test.go @@ -178,309 +178,3 @@ func TestKorLabelFilter(t *testing.T) { } } -func TestIncludeNamespacesFilter(t *testing.T) { - type args struct { - object runtime.Object - opts *Options - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "only include list provided and match", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: []string{"test-ns1", "test-ns2"}, - ExcludeNamespaces: nil, - }, - }, - want: false, - }, - { - name: "only include list provided and no match", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: []string{"test-ns2", "test-ns3"}, - ExcludeNamespaces: nil, - }, - }, - want: true, - }, - { - name: "include list is nil", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: nil, - ExcludeNamespaces: nil, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := IncludeNamespacesFilter(tt.args.object, tt.args.opts); got != tt.want { - t.Errorf("IncludeNamespacesFilter() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestExcludeNamespacesFilter(t *testing.T) { - type args struct { - object runtime.Object - opts *Options - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "only exclude list provided and match", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: nil, - ExcludeNamespaces: []string{"test-ns1", "test-ns2"}, - }, - }, - want: true, - }, - { - name: "only exclude list provided and no match", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: nil, - ExcludeNamespaces: []string{"test-ns2", "test-ns3"}, - }, - }, - want: false, - }, - { - name: "exclude list is nil", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{ - IncludeNamespaces: nil, - ExcludeNamespaces: nil, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ExcludeNamespacesFilter(tt.args.object, tt.args.opts); got != tt.want { - t.Errorf("ExcludeNamespacesFilter() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestSystemNamespaceFilter(t *testing.T) { - type args struct { - object runtime.Object - opts *Options - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "system namespace - default", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "default", - }, - }, - opts: &Options{}, - }, - want: true, - }, - { - name: "system namespace - kube-system", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kube-system", - }, - }, - opts: &Options{}, - }, - want: true, - }, - { - name: "system namespace - kube-public", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kube-public", - }, - }, - opts: &Options{}, - }, - want: true, - }, - { - name: "system namespace - kube-node-lease", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kube-node-lease", - }, - }, - opts: &Options{}, - }, - want: true, - }, - { - name: "non system namespace - test-ns1", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{}, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := SystemNamespaceFilter(tt.args.object, tt.args.opts); got != tt.want { - t.Errorf("SystemNamespaceFilter() = %v, want %v", got, tt.want) - } - }) - } -} - -func testHelperFilterTrue(object runtime.Object, filterOpts *Options) bool { - return true -} - -func testHelperFilterFalse(object runtime.Object, filterOpts *Options) bool { - return false -} - -func TestApplyFilters(t *testing.T) { - type args struct { - object runtime.Object - opts *Options - funcs []FilterFunction - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "false,false,false functions", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{}, - funcs: []FilterFunction{ - testHelperFilterFalse, - testHelperFilterFalse, - testHelperFilterFalse, - }, - }, - want: false, - }, - { - name: "true,false,true functions", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{}, - funcs: []FilterFunction{ - testHelperFilterTrue, - testHelperFilterFalse, - testHelperFilterTrue, - }, - }, - want: true, - }, - { - name: "false,false,true functions", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{}, - funcs: []FilterFunction{ - testHelperFilterFalse, - testHelperFilterFalse, - testHelperFilterTrue, - }, - }, - want: true, - }, - { - name: "true,true,true functions", - args: args{ - object: &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ns1", - }, - }, - opts: &Options{}, - funcs: []FilterFunction{ - testHelperFilterTrue, - testHelperFilterTrue, - testHelperFilterTrue, - }, - }, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ApplyFilters(tt.args.object, tt.args.opts, tt.args.funcs...); got != tt.want { - t.Errorf("ApplyFilters() = %v, want %v", got, tt.want) - } - }) - } -} From 7ce5f1f029257edc91db53e9a1d8f03e31320bad Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 10 Feb 2025 07:36:45 +0000 Subject: [PATCH 08/17] Fix typo --- pkg/filters/filters_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go index 6e241e63..37b495c6 100644 --- a/pkg/filters/filters_test.go +++ b/pkg/filters/filters_test.go @@ -177,4 +177,3 @@ func TestKorLabelFilter(t *testing.T) { }) } } - From 88e49fd2e1fb12e7fbfce683df8d703b42103150 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:00:35 +0000 Subject: [PATCH 09/17] Add extra default namespaces to exceptions list --- pkg/kor/exceptions/namespaces/namespaces.json | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/kor/exceptions/namespaces/namespaces.json b/pkg/kor/exceptions/namespaces/namespaces.json index 1a7d9666..38203fc8 100644 --- a/pkg/kor/exceptions/namespaces/namespaces.json +++ b/pkg/kor/exceptions/namespaces/namespaces.json @@ -15,6 +15,27 @@ { "Namespace": "kube-node-lease", "ResourceName": "" + }, + { + "Namespace": "kuberenetes-dashboard", + "ResourceName": "" + }, + { + "Namespace": "gmp-system", + "ResourceName": "" + }, + { + "Namespace": "local-path-storage", + "ResourceName": "" + }, + { + "Namespace": "assisted-installer", + "ResourceName": "" + }, + { + "Namespace": "openshift-.*", + "ResourceName": "", + "MatchRegex": true } ] } From 7f1edd45630ad769fcd61d124ab2027aeb95f81c Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:07:28 +0000 Subject: [PATCH 10/17] Squeze all params to one line --- pkg/kor/delete.go | 34 +++++----------------------------- pkg/kor/namespaces.go | 30 ++++-------------------------- 2 files changed, 9 insertions(+), 55 deletions(-) diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go index 3b29deca..f7ccf8fe 100644 --- a/pkg/kor/delete.go +++ b/pkg/kor/delete.go @@ -23,7 +23,7 @@ import ( ) func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespace, name string) error { - var deleteResourceApiMap = map[string]func(clientset kubernetes.Interface, namespace, name string) error{ + deleteResourceApiMap := map[string]func(clientset kubernetes.Interface, namespace, name string) error{ "ConfigMap": func(clientset kubernetes.Interface, namespace, name string) error { return clientset.CoreV1().ConfigMaps(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) }, @@ -298,12 +298,7 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa } if !noInteractive { - fmt.Printf( - "Do you want to delete %s %s%s? (Y/N): ", - resourceType, - resource.Name, - namespacedMessageSuffix(namespace), - ) + fmt.Printf("Do you want to delete %s %s%s? (Y/N): ", resourceType, resource.Name, namespacedMessageSuffix(namespace)) var confirmation string _, err := fmt.Scanf("%s\n", &confirmation) if err != nil { @@ -314,12 +309,7 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa if strings.ToLower(confirmation) != "y" && strings.ToLower(confirmation) != "yes" { deletedDiff = append(deletedDiff, resource) - fmt.Printf( - "Do you want flag the resource %s %s%s as In Use? (Y/N): ", - resourceType, - resource.Name, - namespacedMessageSuffix(namespace), - ) + fmt.Printf("Do you want flag the resource %s %s%s as In Use? (Y/N): ", resourceType, resource.Name, namespacedMessageSuffix(namespace)) var inUse string _, err := fmt.Scanf("%s\n", &inUse) if err != nil { @@ -329,14 +319,7 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa if strings.ToLower(inUse) == "y" || strings.ToLower(inUse) == "yes" { if err := FlagResource(clientset, namespace, resourceType, resource.Name); err != nil { - fmt.Fprintf( - os.Stderr, - "Failed to flag resource %s %s%s as In Use: %v\n", - resourceType, - resource.Name, - namespacedMessageSuffix(namespace), - err, - ) + fmt.Fprintf(os.Stderr, "Failed to flag resource %s %s%s as In Use: %v\n", resourceType, resource.Name, namespacedMessageSuffix(namespace), err) } continue } @@ -347,14 +330,7 @@ func DeleteResource(diff []ResourceInfo, clientset kubernetes.Interface, namespa fmt.Printf("Deleting %s %s%s\n", resourceType, resource.Name, namespacedMessageSuffix(namespace)) if err := deleteFunc(clientset, namespace, resource.Name); err != nil { - fmt.Fprintf( - os.Stderr, - "Failed to delete %s %s%s: %v\n", - resourceType, - resource.Name, - namespacedMessageSuffix(namespace), - err, - ) + fmt.Fprintf(os.Stderr, "Failed to delete %s %s%s: %v\n", resourceType, resource.Name, namespacedMessageSuffix(namespace), err) continue } deletedResource := resource diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index c6f34071..3eae9cfd 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -29,12 +29,7 @@ type GenericResource struct { GVR schema.GroupVersionResource } -func processNamespaces( - ctx context.Context, - clientset kubernetes.Interface, - dynamicClient dynamic.Interface, - filterOpts *filters.Options, -) ([]ResourceInfo, error) { +func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *filters.Options) ([]ResourceInfo, error) { var unusedNamespaces []ResourceInfo filteredNamespaceNames := filterOpts.Namespaces(clientset) @@ -137,11 +132,7 @@ func ignorePredefinedResource(gr GenericResource) bool { return false } -func isNamespaceNotEmpty( - gvr *schema.GroupVersionResource, - unstructuredList *unstructured.UnstructuredList, - filterOpts *filters.Options, -) bool { +func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *unstructured.UnstructuredList, filterOpts *filters.Options) bool { for _, unstructuredObj := range unstructuredList.Items { gr := GenericResource{ GVR: *gvr, @@ -163,13 +154,7 @@ func isNamespaceNotEmpty( return false } -func isErrorOrNamespaceContainsResources( - ctx context.Context, - clientset kubernetes.Interface, - dynamicClient dynamic.Interface, - namespace string, - filterOpts *filters.Options, -) (bool, error) { +func isErrorOrNamespaceContainsResources(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { apiResourceLists, err := clientset.Discovery().ServerPreferredNamespacedResources() if err != nil { return true, err @@ -197,14 +182,7 @@ func isErrorOrNamespaceContainsResources( return false, nil } -func GetUnusedNamespaces( - ctx context.Context, - filterOpts *filters.Options, - clientset kubernetes.Interface, - dynamicClient dynamic.Interface, - outputFormat string, - opts common.Opts, -) (string, error) { +func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { resources := make(map[string]map[string][]ResourceInfo) diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts) if err != nil { From c7bc1e6f85712914929f4a89dced2ed7c5f8eea1 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:15:19 +0000 Subject: [PATCH 11/17] Rename GenericResource to NamespacedResource, inner NamespacedName to Identifier, as it contains Name and cannot be called Name --- pkg/kor/namespaces.go | 18 +++++++++--------- pkg/kor/namespaces_test.go | 37 +++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 3eae9cfd..7838c369 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -24,9 +24,9 @@ import ( //go:embed exceptions/namespaces/namespaces.json var namespacesConfig []byte -type GenericResource struct { - NamespacedName types.NamespacedName - GVR schema.GroupVersionResource +type NamespacedResource struct { + Identifier types.NamespacedName + GVR schema.GroupVersionResource } func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *filters.Options) ([]ResourceInfo, error) { @@ -118,15 +118,15 @@ func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { } // TODO: refactor using exception list -func ignorePredefinedResource(gr GenericResource) bool { +func ignorePredefinedResource(resource NamespacedResource) bool { // Specific list of resources to ignore - resources created in all namespaced by default - if gr.GVR.Resource == "configmaps" && gr.GVR.Version == "v1" && gr.NamespacedName.Name == "kube-root-ca.crt" { + if resource.GVR.Resource == "configmaps" && resource.GVR.Version == "v1" && resource.Identifier.Name == "kube-root-ca.crt" { return true } - if gr.GVR.Resource == "serviceaccounts" && gr.GVR.Version == "v1" && gr.NamespacedName.Name == "default" { + if resource.GVR.Resource == "serviceaccounts" && resource.GVR.Version == "v1" && resource.Identifier.Name == "default" { return true } - if gr.GVR.Resource == "events" { + if resource.GVR.Resource == "events" { return true } return false @@ -134,9 +134,9 @@ func ignorePredefinedResource(gr GenericResource) bool { func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *unstructured.UnstructuredList, filterOpts *filters.Options) bool { for _, unstructuredObj := range unstructuredList.Items { - gr := GenericResource{ + gr := NamespacedResource{ GVR: *gvr, - NamespacedName: types.NamespacedName{ + Identifier: types.NamespacedName{ Namespace: unstructuredObj.GetNamespace(), Name: unstructuredObj.GetName(), }, diff --git a/pkg/kor/namespaces_test.go b/pkg/kor/namespaces_test.go index dc4f2ff4..00245f49 100644 --- a/pkg/kor/namespaces_test.go +++ b/pkg/kor/namespaces_test.go @@ -137,16 +137,17 @@ func Test_namespaces_GetGVR(t *testing.T) { }) } } + func Test_namespaces_IgnorePredefinedResource(t *testing.T) { tests := []struct { name string - gr GenericResource + resource NamespacedResource expectedReturn bool }{ { name: "configmap kube-root-ca.crt in default", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "kube-root-ca.crt", Namespace: "default", }, @@ -159,8 +160,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "configmap kube-root-ca.crt in abc", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "kube-root-ca.crt", Namespace: "abc", }, @@ -173,8 +174,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "sa default in default", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "default", Namespace: "default", }, @@ -187,8 +188,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "sa default in cde", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "default", Namespace: "cde", }, @@ -201,8 +202,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "event in default", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "test-event", Namespace: "default", }, @@ -214,8 +215,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "event in qqq", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "test-event", Namespace: "qqq", }, @@ -227,8 +228,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "test-configmap in default", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "test-configmap", Namespace: "default", }, @@ -241,8 +242,8 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { }, { name: "test-serviceaccount in default", - gr: GenericResource{ - NamespacedName: types.NamespacedName{ + resource: NamespacedResource{ + Identifier: types.NamespacedName{ Name: "test-serviceaccount", Namespace: "default", }, @@ -257,7 +258,7 @@ func Test_namespaces_IgnorePredefinedResource(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ignorePredefinedResource(tt.gr) + got := ignorePredefinedResource(tt.resource) if got != tt.expectedReturn { t.Errorf("ignorePredefinedResource() = %t, want %t", got, tt.expectedReturn) } From ce95737e87073e11a3289d5dee1ed3cd445c92d2 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:19:00 +0000 Subject: [PATCH 12/17] Process kor/used label before isErrorOrNamespaceContainsResources --- pkg/kor/namespaces.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 7838c369..a420124d 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -58,18 +58,7 @@ func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dyna continue } - // skipping default resources here - resourceFound, err := isErrorOrNamespaceContainsResources( - ctx, - clientset, - dynamicClient, - namespaceName, - filterOpts, - ) - if err != nil { - return unusedNamespaces, err - } - + // skipping user labeled resources if namespace.Labels["kor/used"] == "false" { unusedNamespaces = append( unusedNamespaces, @@ -78,6 +67,12 @@ func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dyna continue } + // skipping default resources here + resourceFound, err := isErrorOrNamespaceContainsResources(ctx, clientset, dynamicClient, namespaceName, filterOpts) + if err != nil { + return unusedNamespaces, err + } + // construct list of unused namespaces here following a set of rules if !resourceFound { unusedNamespaces = append( @@ -134,7 +129,7 @@ func ignorePredefinedResource(resource NamespacedResource) bool { func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *unstructured.UnstructuredList, filterOpts *filters.Options) bool { for _, unstructuredObj := range unstructuredList.Items { - gr := NamespacedResource{ + resource := NamespacedResource{ GVR: *gvr, Identifier: types.NamespacedName{ Namespace: unstructuredObj.GetNamespace(), @@ -142,11 +137,11 @@ func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *uns }, } // Ignore default cluster resources - if ignorePredefinedResource(gr) { + if ignorePredefinedResource(resource) { continue } // User specified resource type ignore list - if ignoreResourceType(gr.GVR.Resource, filterOpts.IgnoreResourceTypes) { + if ignoreResourceType(resource.GVR.Resource, filterOpts.IgnoreResourceTypes) { continue } return true From 6de1900e1f1b30c554bd77b680d3f9c2c3adb37f Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:23:54 +0000 Subject: [PATCH 13/17] Change error message for GroupVersion slice length --- pkg/kor/namespaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index a420124d..78a712e2 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -99,7 +99,7 @@ func getGVR(name string, splitGV []string) (*schema.GroupVersionResource, error) Resource: name, }, nil default: - return nil, fmt.Errorf("gv is wrong length slice: %d", NumberOfGVPartsFound) + return nil, fmt.Errorf("GroupVersion can only be sliced to 1 or 2 parts, got: %d", NumberOfGVPartsFound) } } From e81703841c11d85b0a9189f264757ec6ad87d9ad Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:31:12 +0000 Subject: [PATCH 14/17] Rename isErrorOrNamespaceContainsResources func to isNamespaceUsed --- pkg/kor/namespaces.go | 4 ++-- pkg/kor/namespacesMoreTests_test.go | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 78a712e2..788a6349 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -68,7 +68,7 @@ func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dyna } // skipping default resources here - resourceFound, err := isErrorOrNamespaceContainsResources(ctx, clientset, dynamicClient, namespaceName, filterOpts) + resourceFound, err := isNamespaceUsed(ctx, clientset, dynamicClient, namespaceName, filterOpts) if err != nil { return unusedNamespaces, err } @@ -149,7 +149,7 @@ func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *uns return false } -func isErrorOrNamespaceContainsResources(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { +func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { apiResourceLists, err := clientset.Discovery().ServerPreferredNamespacedResources() if err != nil { return true, err diff --git a/pkg/kor/namespacesMoreTests_test.go b/pkg/kor/namespacesMoreTests_test.go index 9d579601..3fb4055f 100644 --- a/pkg/kor/namespacesMoreTests_test.go +++ b/pkg/kor/namespacesMoreTests_test.go @@ -131,7 +131,6 @@ func getNamespaceTestSchema(t *testing.T) *runtime.Scheme { t.Errorf("Failed to add appsv1 to scheme: %v", err) } return scheme - } func createHappyDeployFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { @@ -256,7 +255,7 @@ func createDynamicDeployListForcedErrorFakeClientInterfaces(ctx context.Context, type GetFakeClientInterfacesFunc func(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) -func Test_namespaces_IsErrorOrNamespaceContainsResources(t *testing.T) { +func Test_namespaces_IsNamespaceUsed(t *testing.T) { tests := []struct { name string @@ -358,7 +357,7 @@ func Test_namespaces_IsErrorOrNamespaceContainsResources(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { clientset, dynamicClient := tt.getClientsFunc(tt.ctx, t, tt.namespaceName, tt.objName) - got, err := isErrorOrNamespaceContainsResources(tt.ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) + got, err := isNamespaceUsed(tt.ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) if (err != nil) != tt.expectedError { t.Errorf("isErrorOrNamespaceContainsResources() = expected error: %t, got: '%v'", tt.expectedError, err) } From a6433c0bd76e62c5b9a67c99472d42f43891fbd4 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 06:34:47 +0000 Subject: [PATCH 15/17] result of go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e57bf9d2..f9d9b110 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.23.2 require ( github.com/fatih/color v1.18.0 github.com/olekukonko/tablewriter v0.0.5 - github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.20.5 github.com/spf13/cobra v1.9.0 k8s.io/api v0.32.2 @@ -43,6 +42,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect From dee306554479f4fdb4637f4d57ffe7b197804656 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 09:06:16 +0000 Subject: [PATCH 16/17] Remove ignorePredefinedResource() function --- pkg/kor/namespaces.go | 21 +---- pkg/kor/namespaces_test.go | 153 ------------------------------------- 2 files changed, 1 insertion(+), 173 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 788a6349..9f289a69 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -112,21 +112,6 @@ func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { return false } -// TODO: refactor using exception list -func ignorePredefinedResource(resource NamespacedResource) bool { - // Specific list of resources to ignore - resources created in all namespaced by default - if resource.GVR.Resource == "configmaps" && resource.GVR.Version == "v1" && resource.Identifier.Name == "kube-root-ca.crt" { - return true - } - if resource.GVR.Resource == "serviceaccounts" && resource.GVR.Version == "v1" && resource.Identifier.Name == "default" { - return true - } - if resource.GVR.Resource == "events" { - return true - } - return false -} - func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *unstructured.UnstructuredList, filterOpts *filters.Options) bool { for _, unstructuredObj := range unstructuredList.Items { resource := NamespacedResource{ @@ -136,12 +121,8 @@ func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *uns Name: unstructuredObj.GetName(), }, } - // Ignore default cluster resources - if ignorePredefinedResource(resource) { - continue - } // User specified resource type ignore list - if ignoreResourceType(resource.GVR.Resource, filterOpts.IgnoreResourceTypes) { + if ignoreResourceType(resource.GVR.Resource, append(filterOpts.IgnoreResourceTypes, "events")) { continue } return true diff --git a/pkg/kor/namespaces_test.go b/pkg/kor/namespaces_test.go index 00245f49..e2106497 100644 --- a/pkg/kor/namespaces_test.go +++ b/pkg/kor/namespaces_test.go @@ -5,7 +5,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" "github.com/yonahd/kor/pkg/filters" ) @@ -138,134 +137,6 @@ func Test_namespaces_GetGVR(t *testing.T) { } } -func Test_namespaces_IgnorePredefinedResource(t *testing.T) { - tests := []struct { - name string - resource NamespacedResource - expectedReturn bool - }{ - { - name: "configmap kube-root-ca.crt in default", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "kube-root-ca.crt", - Namespace: "default", - }, - GVR: schema.GroupVersionResource{ - Resource: "configmaps", - Version: "v1", - }, - }, - expectedReturn: true, - }, - { - name: "configmap kube-root-ca.crt in abc", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "kube-root-ca.crt", - Namespace: "abc", - }, - GVR: schema.GroupVersionResource{ - Resource: "configmaps", - Version: "v1", - }, - }, - expectedReturn: true, - }, - { - name: "sa default in default", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "default", - Namespace: "default", - }, - GVR: schema.GroupVersionResource{ - Resource: "serviceaccounts", - Version: "v1", - }, - }, - expectedReturn: true, - }, - { - name: "sa default in cde", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "default", - Namespace: "cde", - }, - GVR: schema.GroupVersionResource{ - Resource: "serviceaccounts", - Version: "v1", - }, - }, - expectedReturn: true, - }, - { - name: "event in default", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "test-event", - Namespace: "default", - }, - GVR: schema.GroupVersionResource{ - Resource: "events", - }, - }, - expectedReturn: true, - }, - { - name: "event in qqq", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "test-event", - Namespace: "qqq", - }, - GVR: schema.GroupVersionResource{ - Resource: "events", - }, - }, - expectedReturn: true, - }, - { - name: "test-configmap in default", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "test-configmap", - Namespace: "default", - }, - GVR: schema.GroupVersionResource{ - Resource: "configmaps", - Version: "v1", - }, - }, - expectedReturn: false, - }, - { - name: "test-serviceaccount in default", - resource: NamespacedResource{ - Identifier: types.NamespacedName{ - Name: "test-serviceaccount", - Namespace: "default", - }, - GVR: schema.GroupVersionResource{ - Resource: "serviceaccounts", - Version: "v1", - }, - }, - expectedReturn: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ignorePredefinedResource(tt.resource) - if got != tt.expectedReturn { - t.Errorf("ignorePredefinedResource() = %t, want %t", got, tt.expectedReturn) - } - }) - } -} - func Test_namespaces_IsNamespaceNotEmpty(t *testing.T) { tests := []struct { name string @@ -352,30 +223,6 @@ func Test_namespaces_IsNamespaceNotEmpty(t *testing.T) { }, expectedReturn: false, }, - { - name: "default sa exists but ignored", - gvr: &schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "serviceaccounts", - }, - objects: &unstructured.UnstructuredList{ - Items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ServiceAccount", - "metadata": map[string]interface{}{ - "name": "default", - "namespace": "cde", - }, - }, - }, - }, - }, - filterOpts: &filters.Options{}, - expectedReturn: false, - }, } for _, tt := range tests { From 492a54e45a98432cc72e82ee44ddb4b1b82f3bd5 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 17 Feb 2025 19:14:13 +0000 Subject: [PATCH 17/17] Add group-by for namespaces --- pkg/kor/namespaces.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 9f289a69..62eaf8fa 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -165,12 +165,12 @@ func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clien fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) } - if len(diff) > 0 { - // We consider cluster scope resources in "" (empty string) namespace, as it is common in k8s - if resources[""] == nil { - resources[""] = make(map[string][]ResourceInfo) - } - resources[""]["Namespaces"] = diff + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]ResourceInfo) + resources[""]["Namespace"] = diff + case "resource": + appendResources(resources, "Namespace", "", diff) } if opts.DeleteFlag {