diff --git a/.gitignore b/.gitignore index 66eb6294..b7671576 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ -.vscode *.iml .idea/ +.vscode/ dist/** main.exe -coverage.txt +coverage.* build/ kor !kor/ *.swp main hack/exceptions +.envrc +.tool-versions diff --git a/Makefile b/Makefile index 835f8dda..1e0205d1 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 @@ -13,7 +16,11 @@ lint-fix: golangci-lint run --fix test: - go test -race -coverprofile=coverage.txt -shuffle on ./... + go test -race -coverprofile=coverage.txt -shuffle on ./pkg/... + +cover: test + go tool cover -func=coverage.txt + go tool cover -o coverage.html -html=coverage.txt sort-exception-files: @echo "Sorting exception files..." diff --git a/README.md b/README.md index 29249322..421f00c0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - RoleBindings - VolumeAttachments - PriorityClasses +- Namespaces > **Looking for cost analysis and multi-cluster management?** Check out [KorPro](#korpro), our cloud-based platform built on top of Kor. @@ -144,6 +145,7 @@ Kor provides various subcommands to identify and list unused resources. The avai - `priorityclass` - Gets unused PriorityClasses in the cluster (non-namespaced resource). - `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. @@ -195,6 +197,7 @@ kor [subcommand] --help | HPAs | HPAs not used in Deployments
HPAs not used in StatefulSets | | | Ingresses | Ingresses not pointing at any Service | | | Jobs | Jobs status is completed
Jobs status is suspended
Jobs failed with backoff limit exceeded (including indexed jobs)
Jobs failed with dedaline exceeded | | +| Namespaces | Only empty namespaces | | NetworkPolicies | NetworkPolicies with no Pods selected by podSelector or Ingress / Egress rules | | PDBs | PDBs not used in Deployments / StatefulSets (templates) or in arbitrary Pods
PDBs with empty selectors (match every pod) but no running pods in namespace | | | Pods | Pods in `Failed` phase with reason `Evicted` (i.e., evicted pods)
Pods in Crashloopbackoff | | diff --git a/cmd/kor/all.go b/cmd/kor/all.go index a9d12954..569ed1fb 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..7e1fe0d2 --- /dev/null +++ b/cmd/kor/namespaces.go @@ -0,0 +1,42 @@ +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, opts.ClusterName) + 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 76c4c0d8..8444471b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( k8s.io/apiextensions-apiserver v0.36.2 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 sigs.k8s.io/yaml v1.6.0 ) @@ -66,7 +67,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/pkg/filters/options.go b/pkg/filters/options.go index 557938c8..ccf5fd5e 100644 --- a/pkg/filters/options.go +++ b/pkg/filters/options.go @@ -41,6 +41,8 @@ type Options struct { IncludeNamespaces []string // IgnoreOwnerReferences skips any resource that has ownerReferences set (for all resource types) IgnoreOwnerReferences bool + // 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/create_test_resources.go b/pkg/kor/create_test_resources.go index 0260e97a..ed023bdf 100644 --- a/pkg/kor/create_test_resources.go +++ b/pkg/kor/create_test_resources.go @@ -18,10 +18,12 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -var testNamespace = "test-namespace" -var AppLabels = map[string]string{} -var UsedLabels = map[string]string{"kor/used": "true"} -var UnusedLabels = map[string]string{"kor/used": "false"} +var ( + testNamespace = "test-namespace" + AppLabels = map[string]string{} + UsedLabels = map[string]string{"kor/used": "true"} + UnusedLabels = map[string]string{"kor/used": "false"} +) func CreateTestDeployment(namespace, name string, replicas int32, labels map[string]string) *appsv1.Deployment { return &appsv1.Deployment{ @@ -98,7 +100,6 @@ func CreateTestVolume(name, pvcName string) *corev1.Volume { PersistentVolumeClaim: pvc, }, } - } func CreateEphemeralVolumeDefinition(name, size string) *corev1.Volume { diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go index 1eb8be83..443ad8b8 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{}) }, @@ -93,6 +93,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa "PriorityClass": func(clientset kubernetes.Interface, namespace, name string) error { return clientset.SchedulingV1().PriorityClasses().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 @@ -294,6 +297,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{} @@ -305,7 +315,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 { @@ -316,7 +326,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 { @@ -326,7 +336,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 } @@ -334,9 +344,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..9b3130a2 --- /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": "kubernetes-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 763cbfc5..7b2a7e45 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -9,6 +9,7 @@ import ( "sort" apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -23,6 +24,15 @@ type ExceptionResource struct { ResourceName string MatchRegex bool } + +// All resources in this struct must be defined as regex +type ExceptionNamespacedResource struct { + Namespace string + ResourceName string + MatchRegex bool + ResourceType string +} + type IncludeExcludeLists struct { IncludeListStr string ExcludeListStr string @@ -33,20 +43,22 @@ type ResourceKind struct { } type Config struct { - ExceptionClusterRoles []ExceptionResource `json:"exceptionClusterRoles"` - ExceptionClusterRoleBindings []ExceptionResource `json:"exceptionClusterRoleBindings"` - ExceptionConfigMaps []ExceptionResource `json:"exceptionConfigMaps"` - ExceptionCrds []ExceptionResource `json:"exceptionCrds"` - ExceptionDaemonSets []ExceptionResource `json:"exceptionDaemonSets"` - ExceptionRoles []ExceptionResource `json:"exceptionRoles"` - ExceptionSecrets []ExceptionResource `json:"exceptionSecrets"` - ExceptionServiceAccounts []ExceptionResource `json:"exceptionServiceAccounts"` - ExceptionServices []ExceptionResource `json:"exceptionServices"` - ExceptionStorageClasses []ExceptionResource `json:"exceptionStorageClasses"` - ExceptionJobs []ExceptionResource `json:"exceptionJobs"` - ExceptionPdbs []ExceptionResource `json:"exceptionPdbs"` - ExceptionRoleBindings []ExceptionResource `json:"exceptionRoleBindings"` - ExceptionPriorityClasses []ExceptionResource `json:"exceptionPriorityClasses"` + ExceptionClusterRoles []ExceptionResource `json:"exceptionClusterRoles"` + ExceptionClusterRoleBindings []ExceptionResource `json:"exceptionClusterRoleBindings"` + ExceptionConfigMaps []ExceptionResource `json:"exceptionConfigMaps"` + ExceptionCrds []ExceptionResource `json:"exceptionCrds"` + ExceptionDaemonSets []ExceptionResource `json:"exceptionDaemonSets"` + ExceptionRoles []ExceptionResource `json:"exceptionRoles"` + ExceptionSecrets []ExceptionResource `json:"exceptionSecrets"` + ExceptionServiceAccounts []ExceptionResource `json:"exceptionServiceAccounts"` + ExceptionServices []ExceptionResource `json:"exceptionServices"` + ExceptionStorageClasses []ExceptionResource `json:"exceptionStorageClasses"` + ExceptionJobs []ExceptionResource `json:"exceptionJobs"` + ExceptionPdbs []ExceptionResource `json:"exceptionPdbs"` + ExceptionRoleBindings []ExceptionResource `json:"exceptionRoleBindings"` + ExceptionPriorityClasses []ExceptionResource `json:"exceptionPriorityClasses"` + ExceptionNamespaces []ExceptionResource `json:"exceptionNamespaces"` + ExceptionNamespacedResources []ExceptionNamespacedResource `json:"exceptionNamespacedResources"` // Add other configurations if needed } @@ -157,6 +169,21 @@ func GetDynamicClient(kubeconfig string) *dynamic.DynamicClient { return clientset } +func GetDiscoveryClient(kubeconfig string) *discovery.DiscoveryClient { + config, err := GetConfig(kubeconfig) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load kubeconfig: %v\n", err) + os.Exit(1) + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create Kubernetes client: %v\n", err) + os.Exit(1) + } + return discoveryClient +} + // TODO create formatter by resource "#", "Resource Name", "Namespace" // TODO Functions that use this object are accompanied by repeated data acquisition operations and can be optimized. func CalculateResourceDifference(usedResourceNames []string, allResourceNames []string) []string { @@ -202,6 +229,25 @@ func isResourceException(resourceName, namespace string, exceptions []ExceptionR return match, nil } +func isNamespacedResourceException(resourceName, namespace, resourceType string, exceptions []ExceptionNamespacedResource) (bool, error) { + var match bool + for _, e := range exceptions { + namespaceRegexp, err := regexp.Compile(e.Namespace) + if err != nil { + return false, err + } + nameRegexp, err := regexp.Compile(e.ResourceName) + if err != nil { + return false, err + } + if nameRegexp.MatchString(resourceName) && namespaceRegexp.MatchString(namespace) && e.ResourceType == resourceType { + match = true + break + } + } + return match, nil +} + func unmarshalConfig(data []byte) (*Config, error) { var config Config if err := json.Unmarshal(data, &config); err != nil { diff --git a/pkg/kor/kor_test.go b/pkg/kor/kor_test.go index c5e0c6ca..0a0d1386 100644 --- a/pkg/kor/kor_test.go +++ b/pkg/kor/kor_test.go @@ -250,3 +250,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..eb053cdf --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,319 @@ +package kor + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + "github.com/yonahd/kor/pkg/common" + "github.com/yonahd/kor/pkg/filters" +) + +type NamespacedResource struct { + Identifier types.NamespacedName + GVR schema.GroupVersionResource +} + +var ( + //go:embed all:exceptions + exceptionsFS embed.FS + + //go:embed exceptions/namespaces/namespaces.json + namespacesConfig []byte + + AllNamespacedAPIResourceTypes []*metav1.APIResourceList + NamespacedExceptionsConfig *Config +) + +func isNamespacedResourceType(resourceType string) bool { + for _, apiList := range AllNamespacedAPIResourceTypes { + if apiList == nil { + continue + } + for _, r := range apiList.APIResources { + if r.Name == resourceType || r.SingularName == resourceType || slices.Contains(r.ShortNames, resourceType) || strings.EqualFold(r.Kind, resourceType) { + return r.Namespaced + } + } + } + + return false +} + +func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *filters.Options, apiResourceLists []*metav1.APIResourceList) ([]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(ctx, 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" && !exceptionFound { + unusedNamespaces = append( + unusedNamespaces, + ResourceInfo{Name: namespace.Name, Reason: "Marked with unused label"}, + ) + continue + } + + // skipping default resources here + resourceFound, err := isNamespaceUsed(ctx, dynamicClient, namespaceName, filterOpts, apiResourceLists) + 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(groupVersion string, name string) (*schema.GroupVersionResource, error) { + splitGV := strings.Split(groupVersion, "/") + if groupVersion == "" { + splitGV = []string{} + } + 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 loadNamespacedEmbeddedExceptions(ctx context.Context, fileSystem embed.FS) (*Config, error) { + var exclusions []ExceptionNamespacedResource + + walkFn := func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + if d.IsDir() { + return nil + } + + // match .json suffix (case-insensitive) + if !strings.EqualFold(filepath.Ext(path), ".json") { + return nil + } + + b, err := exceptionsFS.ReadFile(path) + if err != nil { + return nil + } + + // Decode top-level object + var top map[string]json.RawMessage + if err := json.Unmarshal(b, &top); err != nil { + return nil + } + + // skip non-namespaced resource types + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if !isNamespacedResourceType(base) { + return nil + } + + // Require exactly one key + if len(top) != 1 { + return nil + } + + // The value under that key must be an array of ExceptionNamespacedResource + var key string + for k := range top { + key = k + break + } + var items []ExceptionNamespacedResource + if err := json.Unmarshal(top[key], &items); err != nil { + return nil + } + + // Keep only items where MatchRegex == true + for _, it := range items { + if it.MatchRegex { + it.ResourceType = base + exclusions = append(exclusions, it) + } + } + return nil + } + + if err := fs.WalkDir(exceptionsFS, ".", walkFn); err != nil { + return nil, err + } + + // always exclude events + exclusions = append(exclusions, ExceptionNamespacedResource{ + Namespace: ".*", + ResourceName: ".*", + ResourceType: "events", + MatchRegex: true, + }) + + return &Config{ExceptionNamespacedResources: exclusions}, nil +} + +func isNamespaceUsed(ctx context.Context, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options, apiResourceLists []*metav1.APIResourceList) (bool, error) { + config := NamespacedExceptionsConfig + + if apiResourceLists == nil { + return true, nil + } + + // Iterate over all API resources and list instances of each in the specified namespace + for _, apiResourceList := range apiResourceLists { + for _, apiResource := range apiResourceList.APIResources { + gvr, err := getGVR(apiResourceList.GroupVersion, apiResource.Name) + if err != nil { + return true, err + } + + resourcesInNamespace, err := dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + continue + } + + // check if Namespace is Not Empty + for _, resourceInNamespace := range resourcesInNamespace.Items { + resource := NamespacedResource{ + GVR: *gvr, + Identifier: types.NamespacedName{ + Namespace: resourceInNamespace.GetNamespace(), + Name: resourceInNamespace.GetName(), + }, + } + + // User specified resource type ignore list + if ignoreResourceType(resource.GVR.Resource, filterOpts.IgnoreResourceTypes) { + continue + } + + // ignore namespaced resources within exception list + exceptionFound, err := isNamespacedResourceException(resource.Identifier.Name, resource.Identifier.Namespace, resource.GVR.Resource, config.ExceptionNamespacedResources) + if err != nil { + return true, err + } + if exceptionFound { + continue + } + + 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) { + allNamespacedAPIs, err := clientset.Discovery().ServerPreferredNamespacedResources() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: error fetching preferred resources (partial results may be available): %v", err) + } + AllNamespacedAPIResourceTypes = allNamespacedAPIs + + NamespacedExceptionsConfig, err = loadNamespacedEmbeddedExceptions(ctx, exceptionsFS) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespaced exceptions: %v\n", err) + } + + resources := make(map[string]map[string][]ResourceInfo) + diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts, allNamespacedAPIs) + 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/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go new file mode 100644 index 00000000..6c0ccacd --- /dev/null +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -0,0 +1,442 @@ +package kor + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + 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" + + "github.com/yonahd/kor/pkg/common" + "github.com/yonahd/kor/pkg/filters" +) + +type GetFakeClientInterfacesForGetUnusedNamespacesTestFunc func(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) + +func defineNewTypeEventObject(ns, name string) *eventsv1.Event { + return &eventsv1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + ReportingController: "some-controller", + Type: "Warning", + } +} + +func defineServiceAccountObject(ns, name string) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + } +} + +func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "test-namespace" + namespace1 := defineNamespaceObject(ns1) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + sa1 := "default" + serviceAccount1 := defineServiceAccountObject(ns1, sa1) + _, err = clientset.CoreV1().ServiceAccounts(ns1).Create(ctx, serviceAccount1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test ServiceAccount: %v", err) + } + objects = append(objects, serviceAccount1) + + cm1 := "openshift-service-ca.crt" + configmap1 := defineConfigMapObject(ns1, cm1) + _, err = clientset.CoreV1().ConfigMaps(ns1).Create(ctx, configmap1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test ConfigMap: %v", err) + } + objects = append(objects, configmap1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "test-namespace" + namespace1 := defineNamespaceObject(ns1) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + sa1 := "my-app" + serviceAccount1 := defineServiceAccountObject(ns1, sa1) + _, err = clientset.CoreV1().ServiceAccounts(ns1).Create(ctx, serviceAccount1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test service account: %v", err) + } + objects = append(objects, serviceAccount1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "empty-namespace" + namespace1 := defineNamespaceObject(ns1) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + evtName := "some-random-event" + newEventType := defineNewTypeEventObject(ns1, evtName) + _, err = clientset.EventsV1().Events(ns1).Create(ctx, newEventType, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test event of group events.k8s.io: %v", err) + } + objects = append(objects, newEventType) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "nonempty-namespace-labeled" + namespace1 := defineNamespaceObject(ns1) + namespace1.Labels = map[string]string{ + "kor/used": "false", + } + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + sa1 := "my-app" + serviceAccount1 := defineServiceAccountObject(ns1, sa1) + _, err = clientset.CoreV1().ServiceAccounts(ns1).Create(ctx, serviceAccount1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test service account: %v", err) + } + objects = append(objects, serviceAccount1) + + ns2 := "test-namespace" + namespace2 := defineNamespaceObject(ns2) + _, err = clientset.CoreV1().Namespaces().Create(ctx, namespace2, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace2) + + sa2 := "another-app" + serviceAccount2 := defineServiceAccountObject(ns2, sa2) + _, err = clientset.CoreV1().ServiceAccounts(ns2).Create(ctx, serviceAccount2, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test service account: %v", err) + } + objects = append(objects, serviceAccount2) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "empty-namespace-labeled" + namespace1 := defineNamespaceObject(ns1) + namespace1.Labels = map[string]string{ + "kor/used": "true", + } + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "test-namespace" + namespace1 := defineNamespaceObject(ns1) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + cm1 := "test-configmap" + configmap1 := defineConfigMapObject(ns1, cm1) + _, err = clientset.CoreV1().ConfigMaps(ns1).Create(ctx, configmap1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test configmap: %v", err) + } + objects = append(objects, configmap1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "kube-system" + namespace1 := defineNamespaceObject(ns1) + namespace1.Labels = map[string]string{ + "kor/used": "false", + } + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + scheme := getNamespaceTestSchema(t) + objects := []runtime.Object{} + + ns1 := "kube-system" + namespace1 := defineNamespaceObject(ns1) + _, err := clientset.CoreV1().Namespaces().Create(ctx, namespace1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test namespace: %v", err) + } + objects = append(objects, namespace1) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + {Group: "events.k8s.io", Version: "v1", Resource: "events"}: "EventList", + {Group: "", Version: "v1", Resource: "events"}: "EventList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func TestGetUnusedNamespaces(t *testing.T) { + tests := []struct { + name string + + getClientsFunc GetFakeClientInterfacesForGetUnusedNamespacesTestFunc + + filterOpts *filters.Options + + expectedOutput string + expectedError bool + }{ + { + name: "Namespace contains only ignored by default resource types", + getClientsFunc: createEmptyNamespace, + filterOpts: &filters.Options{}, + expectedOutput: `{ + "": { + "Namespace": [ + "empty-namespace" + ] + } +}`, + expectedError: false, + }, + { + name: "Namespace contains only ignored by default resource", + getClientsFunc: createEmptyNamespaceWithIgnoredByDefaultResource, + filterOpts: &filters.Options{}, + expectedOutput: `{ + "": { + "Namespace": [ + "test-namespace" + ] + } +}`, + expectedError: false, + }, + { + name: "Namespace contains non ignored by default resource", + getClientsFunc: createNonEmptyNamespace, + filterOpts: &filters.Options{}, + expectedOutput: `{}`, + expectedError: false, + }, + { + name: "Nonempty Namespace contains kor/used=false label", + getClientsFunc: createNonEmptyNamespaceLabeledAsUnused, + filterOpts: &filters.Options{}, + expectedOutput: `{ + "": { + "Namespace": [ + "nonempty-namespace-labeled" + ] + } +}`, + expectedError: false, + }, + { + name: "Empty Namespace contains kor/used=true label", + getClientsFunc: createEmptyNamespaceLabeledAsUsed, + filterOpts: &filters.Options{}, + expectedOutput: `{}`, + expectedError: false, + }, + { + name: "kube-system special Namespace", + getClientsFunc: createKubeSystemNamespace, + filterOpts: &filters.Options{}, + expectedOutput: `{}`, + expectedError: false, + }, + { + name: "kube-system special Namespace contains kor/used=false label", + getClientsFunc: createKubeSystemNamespaceWithKorUnusedLabel, + filterOpts: &filters.Options{}, + expectedOutput: `{}`, + expectedError: false, + }, + { + name: "Namespace with configmap and with filter IgnoreResourceTypes configmaps applied", + getClientsFunc: namespaceWithIgnoredConfigmap, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{ + "configmaps", + }, + }, + expectedOutput: `{ + "": { + "Namespace": [ + "test-namespace" + ] + } +}`, + expectedError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + opts := common.Opts{ + WebhookURL: "", + Channel: "", + Token: "", + DeleteFlag: false, + NoInteractive: true, + GroupBy: "namespace", + } + + clientset, dynamicClient := tt.getClientsFunc(ctx, t) + got, err := GetUnusedNamespaces(ctx, tt.filterOpts, clientset, dynamicClient, "json", opts) + if (err != nil) != tt.expectedError { + t.Errorf("GetUnusedNamespaces() = expected error: %t, got: '%v'", tt.expectedError, err) + } + if got != tt.expectedOutput { + t.Errorf("GetUnusedNamespaces() = got:\n'%s'\nwant:\n'%s'", got, tt.expectedOutput) + } + }) + } +} diff --git a/pkg/kor/namespaces_IsNamespaceUsed_test.go b/pkg/kor/namespaces_IsNamespaceUsed_test.go new file mode 100644 index 00000000..a0994034 --- /dev/null +++ b/pkg/kor/namespaces_IsNamespaceUsed_test.go @@ -0,0 +1,458 @@ +package kor + +import ( + "context" + "fmt" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/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", + }, + }, + }, + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "configmaps", + Namespaced: true, + Kind: "ConfigMap", + }, + }, + }, + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "secrets", + Namespaced: true, + Kind: "Secret", + }, + }, + }, + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "serviceaccounts", + Namespaced: true, + Kind: "ServiceAccount", + }, + }, + }, + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "events", + Namespaced: true, + Kind: "Event", + }, + }, + }, + { + GroupVersion: "events.k8s.io/v1", + APIResources: []metav1.APIResource{ + { + Name: "events", + Namespaced: true, + Kind: "Event", + }, + }, + }, + }, 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 +} + +func defineConfigMapObject(ns, name string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + } +} + +// 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) + } + err = eventsv1.AddToScheme(scheme) + if err != nil { + t.Errorf("Failed to add eventsv1 to scheme: %v", err) + } + return scheme +} + +func createHappyDeployFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + objects := []runtime.Object{} + + 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) + } + objects = append(objects, namespace) + + deployment := defineDeployObject(ns, name) + _, err = clientset.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Failed to create test deployment: %v", err) + } + objects = append(objects, deployment) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createHappyEmptyFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + objects := []runtime.Object{} + + 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) + } + objects = append(objects, namespace) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createUnhappyDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeUnhappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + objects := []runtime.Object{} + + 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) + } + objects = append(objects, namespace) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createBrokenAPIResourceListDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeBrokenAPIResourceListDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + objects := []runtime.Object{} + + 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) + } + objects = append(objects, namespace) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + + return clientset, dynamicClient +} + +func createDynamicDeployListForcedErrorFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewClientset() + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} + objects := []runtime.Object{} + + 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) + } + objects = append(objects, namespace) + + 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) + } + objects = append(objects, deployment) + + listKinds := map[schema.GroupVersionResource]string{ + {Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList", + {Group: "", Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) + 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 GetFakeClientInterfacesForIsNamespaceUsedTestFunc func(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) + +func TestIsNamespaceUsed(t *testing.T) { + tests := []struct { + name string + + objName string + namespaceName string + getClientsFunc GetFakeClientInterfacesForIsNamespaceUsedTestFunc + filterOpts *filters.Options + + expectedReturn bool + expectedError bool + }{ + { + name: "deployment exists, no errors, ignoring secrets and configmaps", + + objName: "test-object", + namespaceName: "test-namespace", + 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", + 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", + 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", + getClientsFunc: createUnhappyDiscoveryFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: true, + expectedError: false, + }, + { + name: "imitate broken APIResourceList, error in discovery, ignoring secrets", + + objName: "test-object", + namespaceName: "test-namespace", + 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", + getClientsFunc: createDynamicDeployListForcedErrorFakeClientInterfaces, + filterOpts: &filters.Options{ + IgnoreResourceTypes: []string{"secrets"}, + }, + + expectedReturn: false, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + NamespacedExceptionsConfig = &Config{ + ExceptionNamespacedResources: []ExceptionNamespacedResource{ + {Namespace: ".*", ResourceName: "kube-root-ca\\.crt", MatchRegex: true, ResourceType: "configmaps"}, + {Namespace: ".*", ResourceName: "openshift-service-ca\\.crt", MatchRegex: true, ResourceType: "configmaps"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "configmaps"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "jobs"}, + {Namespace: "kube-system", ResourceName: "system::*", MatchRegex: true, ResourceType: "rolebindings"}, + {Namespace: "kube-system", ResourceName: "system:controller:*", MatchRegex: true, ResourceType: "rolebindings"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "roles"}, + {Namespace: "kube-system", ResourceName: ".*\\.node-password\\.k3s", MatchRegex: true, ResourceType: "secrets"}, + {Namespace: "kube-system", ResourceName: "bootstrap-token-.*", MatchRegex: true, ResourceType: "secrets"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "secrets"}, + {Namespace: ".*", ResourceName: "default", MatchRegex: true, ResourceType: "serviceaccounts"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "serviceaccounts"}, + {Namespace: "openshift-.*", ResourceName: ".*", MatchRegex: true, ResourceType: "services"}, + {Namespace: ".*", ResourceName: ".*", MatchRegex: true, ResourceType: "events"}, + }, + } + clientset, dynamicClient := tt.getClientsFunc(ctx, t, tt.namespaceName, tt.objName) + apiResourceLists, _ := clientset.Discovery().ServerPreferredNamespacedResources() + got, err := isNamespaceUsed(ctx, dynamicClient, tt.namespaceName, tt.filterOpts, apiResourceLists) + if (err != nil) != tt.expectedError { + t.Errorf("isNamespaceUsed() = expected error: %t, got: '%v'", tt.expectedError, err) + } + if got != tt.expectedReturn { + t.Errorf("isNamespaceUsed() = 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..5a60f58f --- /dev/null +++ b/pkg/kor/namespaces_test.go @@ -0,0 +1,126 @@ +package kor + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestIgnoreResourceType(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 TestGetGVR(t *testing.T) { + type args struct { + groupVersion string + name string + } + tests := []struct { + name string + args args + want *schema.GroupVersionResource + expectErr bool + }{ + { + name: "number of parts 0 - expect error", + args: args{ + groupVersion: "", + name: "deployments", + }, + want: nil, + expectErr: true, + }, + { + name: "number of parts 1", + args: args{ + groupVersion: "v1", + name: "secrets", + }, + want: &schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "secrets", + }, + expectErr: false, + }, + { + name: "number of parts 2", + args: args{ + groupVersion: "apps/v1", + name: "deployments", + }, + want: &schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + }, + expectErr: false, + }, + { + name: "number of parts 4 - expect error", + args: args{ + groupVersion: "apps/v1/test-deploy01", + name: "deployments", + }, + want: nil, + expectErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getGVR(tt.args.groupVersion, tt.args.name) + 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) + } + }) + } +}