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 08db44f2..6de26a6d 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,11 @@ lint-fix: test: go test -race -coverprofile=coverage.txt -shuffle on ./... +# TODO: EZ: remove once finished +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)' -exec sh -c ' \ @@ -36,4 +44,4 @@ validate-exception-sorting: done; \ if [ "$$PRINT_ERR" = 0 ]; then \ echo "Run the following command to sort all files recursively: make sort-exception-files"; \ - fi; \ \ No newline at end of file + fi; \ diff --git a/README.md b/README.md index 80fdee83..cb7951dc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - DaemonSets - StorageClasses - NetworkPolicies +- Namespaces ![Kor Screenshot](/images/show_reason_screenshot.png) @@ -118,6 +119,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. 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/go.mod b/go.mod index b4494709..8ea9044f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.22.2 require ( github.com/fatih/color v1.17.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.19.1 github.com/spf13/cobra v1.8.1 k8s.io/api v0.30.2 @@ -43,7 +44,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/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect 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 7edf0da6..db9a3fc1 100644 --- a/pkg/filters/options.go +++ b/pkg/filters/options.go @@ -38,6 +38,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 a1e6f687..37e290a4 100644 --- a/pkg/kor/delete.go +++ b/pkg/kor/delete.go @@ -81,6 +81,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa "NetworkPolicy": func(clientset kubernetes.Interface, namespace, name string) error { return clientset.NetworkingV1().NetworkPolicies(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 @@ -270,6 +273,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{} @@ -281,7 +291,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 { @@ -292,7 +307,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 { @@ -302,7 +322,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 } @@ -310,9 +337,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) + } + }) + } +}