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..fec39ec3 --- /dev/null +++ b/cmd/kor/namespaces.go @@ -0,0 +1,43 @@ +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/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..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{}) }, @@ -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,7 @@ 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 +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 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 +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 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 +327,10 @@ 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/exceptions/namespaces/namespaces.json b/pkg/kor/exceptions/namespaces/namespaces.json new file mode 100644 index 00000000..38203fc8 --- /dev/null +++ b/pkg/kor/exceptions/namespaces/namespaces.json @@ -0,0 +1,41 @@ +{ + "exceptionNamespaces": [ + { + "Namespace": "default", + "ResourceName": "" + }, + { + "Namespace": "kube-system", + "ResourceName": "" + }, + { + "Namespace": "kube-public", + "ResourceName": "" + }, + { + "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 + } + ] +} 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/kor_test.go b/pkg/kor/kor_test.go index eafddb17..9ba080f4 100644 --- a/pkg/kor/kor_test.go +++ b/pkg/kor/kor_test.go @@ -198,3 +198,40 @@ 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, + ) + } + }) + } +} diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go new file mode 100644 index 00000000..62eaf8fa --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,200 @@ +package kor + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + + 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/common" + "github.com/yonahd/kor/pkg/filters" +) + +//go:embed exceptions/namespaces/namespaces.json +var namespacesConfig []byte + +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) { + 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 user labeled resources + if namespace.Labels["kor/used"] == "false" { + unusedNamespaces = append( + unusedNamespaces, + ResourceInfo{Name: namespace.Name, Reason: "Marked with unused label"}, + ) + continue + } + + // skipping default resources here + resourceFound, err := isNamespaceUsed(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( + 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: + 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("GroupVersion can only be sliced to 1 or 2 parts, got: %d", NumberOfGVPartsFound) + } +} + +func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { + for _, ignoreType := range ignoreResourceTypes { + if resource == ignoreType { + return true + } + } + return false +} + +func isNamespaceNotEmpty(gvr *schema.GroupVersionResource, unstructuredList *unstructured.UnstructuredList, filterOpts *filters.Options) bool { + for _, unstructuredObj := range unstructuredList.Items { + resource := NamespacedResource{ + GVR: *gvr, + Identifier: types.NamespacedName{ + Namespace: unstructuredObj.GetNamespace(), + Name: unstructuredObj.GetName(), + }, + } + // User specified resource type ignore list + if ignoreResourceType(resource.GVR.Resource, append(filterOpts.IgnoreResourceTypes, "events")) { + continue + } + return true + } + return false +} + +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 + } + + // 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 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 { + fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) + } + + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]ResourceInfo) + resources[""]["Namespace"] = diff + case "resource": + appendResources(resources, "Namespace", "", 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..3fb4055f --- /dev/null +++ b/pkg/kor/namespacesMoreTests_test.go @@ -0,0 +1,369 @@ +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_IsNamespaceUsed(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 := 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) + } + 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..e2106497 --- /dev/null +++ b/pkg/kor/namespaces_test.go @@ -0,0 +1,236 @@ +package kor + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "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_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, + }, + } + + 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) + } + }) + } +}