From af02af222285df68e696e48995655dbd68ede1b5 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sat, 28 Jun 2025 08:28:38 +0100 Subject: [PATCH 01/15] feat(namespaces): add empty namespace detection and removal --- .gitignore | 6 +- Makefile | 7 + README.md | 3 + cmd/kor/all.go | 2 +- cmd/kor/namespaces.go | 43 ++ go.mod | 2 + pkg/filters/options.go | 2 + pkg/kor/create_test_resources.go | 11 +- pkg/kor/delete.go | 23 +- .../namespaced-resources.json | 28 ++ pkg/kor/exceptions/namespaces/namespaces.json | 41 ++ pkg/kor/kor.go | 57 ++- pkg/kor/kor_test.go | 37 ++ pkg/kor/namespaces.go | 215 +++++++++ .../namespaces_GetUnusedNamespaces_test.go | 442 ++++++++++++++++++ pkg/kor/namespaces_IsNamespaceUsed_test.go | 439 +++++++++++++++++ pkg/kor/namespaces_test.go | 126 +++++ 17 files changed, 1456 insertions(+), 28 deletions(-) create mode 100644 cmd/kor/namespaces.go create mode 100644 pkg/kor/exceptions/namespaced-resources/namespaced-resources.json create mode 100644 pkg/kor/exceptions/namespaces/namespaces.json create mode 100644 pkg/kor/namespaces.go create mode 100644 pkg/kor/namespaces_GetUnusedNamespaces_test.go create mode 100644 pkg/kor/namespaces_IsNamespaceUsed_test.go create mode 100644 pkg/kor/namespaces_test.go 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..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 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..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/go.mod b/go.mod index 76c4c0d8..d7812496 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/yonahd/kor go 1.26.0 +toolchain go1.24.1 + require ( github.com/fatih/color v1.19.0 github.com/jarcoal/httpmock v1.4.1 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/namespaced-resources/namespaced-resources.json b/pkg/kor/exceptions/namespaced-resources/namespaced-resources.json new file mode 100644 index 00000000..4b2f97d5 --- /dev/null +++ b/pkg/kor/exceptions/namespaced-resources/namespaced-resources.json @@ -0,0 +1,28 @@ +{ + "exceptionNamespacedResources": [ + { + "Namespace": ".*", + "ResourceName": ".*", + "ResourceType": "events", + "MatchRegex": true + }, + { + "Namespace": ".*", + "ResourceName": "default", + "ResourceType": "serviceaccounts", + "MatchRegex": true + }, + { + "Namespace": ".*", + "ResourceName": "openshift-service-ca\\.crt", + "ResourceType": "configmaps", + "MatchRegex": true + }, + { + "Namespace": ".*", + "ResourceName": "kube-root-ca\\.crt", + "ResourceType": "configmaps", + "MatchRegex": true + } + ] +} 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 763cbfc5..83cf7d2d 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -23,6 +23,14 @@ type ExceptionResource struct { ResourceName string MatchRegex bool } + +// All resources in this struct must be defined as regex +type ExceptionNamespacedResource struct { + Namespace string + ResourceName string + ResourceType string +} + type IncludeExcludeLists struct { IncludeListStr string ExcludeListStr string @@ -33,20 +41,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 } @@ -202,6 +212,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..7494999d --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,215 @@ +package kor + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + "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" + _ "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 + +//go:embed exceptions/namespaced-resources/namespaced-resources.json +var namespacedResourcesConfig []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" && !exceptionFound { + 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(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 isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { + config, err := unmarshalConfig(namespacedResourcesConfig) + if err != nil { + return true, err + } + + 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 { + 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) { + 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/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go new file mode 100644 index 00000000..4e5bb2c7 --- /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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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 namespaceWithIgnoredConfgimap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + realClientset := fake.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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: namespaceWithIgnoredConfgimap, + 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..ec63730a --- /dev/null +++ b/pkg/kor/namespaces_IsNamespaceUsed_test.go @@ -0,0 +1,439 @@ +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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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.NewSimpleClientset() + 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: true, + }, + { + 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() + clientset, dynamicClient := tt.getClientsFunc(ctx, t, tt.namespaceName, tt.objName) + got, err := isNamespaceUsed(ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) + 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) + } + }) + } +} From 29f88c335b61fcbf40c7987f4c686030a35455a5 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Fri, 3 Oct 2025 04:44:13 +0100 Subject: [PATCH 02/15] Resolve issues identified by copilot --- pkg/kor/exceptions/namespaces/namespaces.json | 2 +- pkg/kor/namespaces.go | 2 +- pkg/kor/namespaces_GetUnusedNamespaces_test.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/kor/exceptions/namespaces/namespaces.json b/pkg/kor/exceptions/namespaces/namespaces.json index 38203fc8..9b3130a2 100644 --- a/pkg/kor/exceptions/namespaces/namespaces.json +++ b/pkg/kor/exceptions/namespaces/namespaces.json @@ -17,7 +17,7 @@ "ResourceName": "" }, { - "Namespace": "kuberenetes-dashboard", + "Namespace": "kubernetes-dashboard", "ResourceName": "" }, { diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 7494999d..d42a52b6 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -42,7 +42,7 @@ func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dyna } for _, namespaceName := range filteredNamespaceNames { - namespace, err := clientset.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{}) + namespace, err := clientset.CoreV1().Namespaces().Get(ctx, namespaceName, metav1.GetOptions{}) if err != nil { return nil, err } diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index 4e5bb2c7..ce7f127a 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -232,7 +232,7 @@ func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kuber return clientset, dynamicClient } -func namespaceWithIgnoredConfgimap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} @@ -401,7 +401,7 @@ func TestGetUnusedNamespaces(t *testing.T) { }, { name: "Namespace with configmap and with filter IgnoreResourceTypes configmaps applied", - getClientsFunc: namespaceWithIgnoredConfgimap, + getClientsFunc: namespaceWithIgnoredConfigmap, filterOpts: &filters.Options{ IgnoreResourceTypes: []string{ "configmaps", From 03fc6b2589f360a41b472cc2767b60995900f4a5 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 5 Oct 2025 11:51:50 +0100 Subject: [PATCH 03/15] load all exclusions via embed and resuing instead maintaining separate namespaced-resources.json --- cmd/kor/namespaces.go | 3 +- .../namespaced-resources.json | 28 ---- pkg/kor/kor.go | 17 +++ pkg/kor/namespaces.go | 142 ++++++++++++++++-- .../namespaces_GetUnusedNamespaces_test.go | 64 ++++---- 5 files changed, 186 insertions(+), 68 deletions(-) delete mode 100644 pkg/kor/exceptions/namespaced-resources/namespaced-resources.json diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go index fec39ec3..c11768ba 100644 --- a/cmd/kor/namespaces.go +++ b/cmd/kor/namespaces.go @@ -19,8 +19,9 @@ var namespaceCmd = &cobra.Command{ ctx := context.Background() clientset := kor.GetKubeClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) + dicoveryClient := kor.GetDiscoveryClient(kubeconfig) - if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil { + if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, dicoveryClient, outputFormat, opts); err != nil { fmt.Println(err) } else { utils.PrintLogo(outputFormat) diff --git a/pkg/kor/exceptions/namespaced-resources/namespaced-resources.json b/pkg/kor/exceptions/namespaced-resources/namespaced-resources.json deleted file mode 100644 index 4b2f97d5..00000000 --- a/pkg/kor/exceptions/namespaced-resources/namespaced-resources.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "exceptionNamespacedResources": [ - { - "Namespace": ".*", - "ResourceName": ".*", - "ResourceType": "events", - "MatchRegex": true - }, - { - "Namespace": ".*", - "ResourceName": "default", - "ResourceType": "serviceaccounts", - "MatchRegex": true - }, - { - "Namespace": ".*", - "ResourceName": "openshift-service-ca\\.crt", - "ResourceType": "configmaps", - "MatchRegex": true - }, - { - "Namespace": ".*", - "ResourceName": "kube-root-ca\\.crt", - "ResourceType": "configmaps", - "MatchRegex": true - } - ] -} diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go index 83cf7d2d..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" @@ -28,6 +29,7 @@ type ExceptionResource struct { type ExceptionNamespacedResource struct { Namespace string ResourceName string + MatchRegex bool ResourceType string } @@ -167,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 { diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index d42a52b6..c0981c91 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -3,34 +3,58 @@ package kor import ( "bytes" "context" - _ "embed" + "embed" "encoding/json" "fmt" + "io/fs" "os" + "path/filepath" "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/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + "k8s.io/utils/strings/slices" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" ) -//go:embed exceptions/namespaces/namespaces.json -var namespacesConfig []byte - -//go:embed exceptions/namespaced-resources/namespaced-resources.json -var namespacedResourcesConfig []byte - 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 + + Exclusions []ExceptionNamespacedResource + 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) ([]ResourceInfo, error) { var unusedNamespaces []ResourceInfo @@ -118,12 +142,95 @@ func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { return false } -func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { - config, err := unmarshalConfig(namespacedResourcesConfig) +func exceptionsFSWalkFn(path string, d fs.DirEntry, err error) error { if err != nil { - return true, err + return nil } + if d.IsDir() { + return nil + } + + // match .json suffix (case-insensitive) + if strings.EqualFold(filepath.Ext(path), ".json") { + 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 + } + if len(items) == 0 { + return nil + } + + // Keep only items where MatchRegex == true + var kept []ExceptionNamespacedResource + for _, it := range items { + if it.MatchRegex { + kept = append(kept, it) + } + } + if len(kept) == 0 { + return nil + } + // Set ResourceType for each kept item from the file base name + for i := range kept { + kept[i].ResourceType = base + } + Exclusions = append(Exclusions, kept...) + } + return nil +} + +func loadNamespacedEmbeddedExceptions( + ctx context.Context, + fileSystem embed.FS, +) (*Config, error) { + if err := fs.WalkDir(exceptionsFS, ".", exceptionsFSWalkFn); err != nil { + return nil, err + } + // always exclude events + Exclusions = append(Exclusions, ExceptionNamespacedResource{ + Namespace: ".*", + ResourceName: ".*", + ResourceType: "events", + MatchRegex: true, + }, + ) + config := &Config{ + ExceptionNamespacedResources: Exclusions, + } + return config, nil +} + +func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { + config := NamespacedExceptionsConfig apiResourceLists, err := clientset.Discovery().ServerPreferredNamespacedResources() if err != nil { return true, err @@ -173,7 +280,18 @@ func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynami return false, nil } -func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { +func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, discoveryClient discovery.DiscoveryInterface, outputFormat string, opts common.Opts) (string, error) { + allNamespacedAPIs, err := discoveryClient.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) if err != nil { diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index ce7f127a..4ba2f817 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -9,6 +9,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + discovery "k8s.io/client-go/discovery" discoveryfake "k8s.io/client-go/discovery/fake" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" @@ -18,7 +19,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -type GetFakeClientInterfacesForGetUnusedNamespacesTestFunc func(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) +type GetFakeClientInterfacesForGetUnusedNamespacesTestFunc func(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) func defineNewTypeEventObject(ns, name string) *eventsv1.Event { return &eventsv1.Event{ @@ -40,9 +41,10 @@ func defineServiceAccountObject(ns, name string) *corev1.ServiceAccount { } } -func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -79,12 +81,13 @@ func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *te } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -113,12 +116,13 @@ func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Inte } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -147,12 +151,13 @@ func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interfa } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -200,12 +205,13 @@ func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) ( } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -229,12 +235,13 @@ func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kuber } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -263,12 +270,13 @@ func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernete } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -292,12 +300,13 @@ func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } -func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { +func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { realClientset := fake.NewSimpleClientset() - fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} + fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} + fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -318,7 +327,7 @@ func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.In } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient + return clientset, dynamicClient, fakeDisc } func TestGetUnusedNamespaces(t *testing.T) { @@ -429,8 +438,9 @@ func TestGetUnusedNamespaces(t *testing.T) { GroupBy: "namespace", } - clientset, dynamicClient := tt.getClientsFunc(ctx, t) - got, err := GetUnusedNamespaces(ctx, tt.filterOpts, clientset, dynamicClient, "json", opts) + clientset, dynamicClient, discoveryClient := tt.getClientsFunc(ctx, t) + // TODO: EZ: fix fake clients + got, err := GetUnusedNamespaces(ctx, tt.filterOpts, clientset, dynamicClient, discoveryClient, "json", opts) if (err != nil) != tt.expectedError { t.Errorf("GetUnusedNamespaces() = expected error: %t, got: '%v'", tt.expectedError, err) } From 617d5dda151478c1760527afd066810b87334593 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 5 Oct 2025 19:36:55 +0100 Subject: [PATCH 04/15] Fix typo --- pkg/kor/namespaces_IsNamespaceUsed_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/kor/namespaces_IsNamespaceUsed_test.go b/pkg/kor/namespaces_IsNamespaceUsed_test.go index ec63730a..24f8f056 100644 --- a/pkg/kor/namespaces_IsNamespaceUsed_test.go +++ b/pkg/kor/namespaces_IsNamespaceUsed_test.go @@ -426,6 +426,24 @@ func TestIsNamespaceUsed(t *testing.T) { 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) got, err := isNamespaceUsed(ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) if (err != nil) != tt.expectedError { From 0a88e0cc93d0e0e675e4eeff8799524adacdc78f Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 5 Oct 2025 19:57:32 +0100 Subject: [PATCH 05/15] Fix typo --- cmd/kor/namespaces.go | 4 +- pkg/kor/namespaces.go | 6 +- .../namespaces_GetUnusedNamespaces_test.go | 64 ++++++++----------- 3 files changed, 32 insertions(+), 42 deletions(-) diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go index c11768ba..1c423680 100644 --- a/cmd/kor/namespaces.go +++ b/cmd/kor/namespaces.go @@ -19,9 +19,9 @@ var namespaceCmd = &cobra.Command{ ctx := context.Background() clientset := kor.GetKubeClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) - dicoveryClient := kor.GetDiscoveryClient(kubeconfig) + // dicoveryClient := kor.GetDiscoveryClient(kubeconfig) - if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, dicoveryClient, outputFormat, opts); err != nil { + if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil { fmt.Println(err) } else { utils.PrintLogo(outputFormat) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index c0981c91..427efd5e 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -14,7 +14,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" types "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/utils/strings/slices" @@ -280,8 +279,9 @@ func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynami return false, nil } -func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, discoveryClient discovery.DiscoveryInterface, outputFormat string, opts common.Opts) (string, error) { - allNamespacedAPIs, err := discoveryClient.ServerPreferredNamespacedResources() +func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { + // TODO: EZ: func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, discoveryClient discovery.DiscoveryInterface, 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) } diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index 4ba2f817..ce7f127a 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -9,7 +9,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - discovery "k8s.io/client-go/discovery" discoveryfake "k8s.io/client-go/discovery/fake" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" @@ -19,7 +18,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -type GetFakeClientInterfacesForGetUnusedNamespacesTestFunc func(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) +type GetFakeClientInterfacesForGetUnusedNamespacesTestFunc func(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) func defineNewTypeEventObject(ns, name string) *eventsv1.Event { return &eventsv1.Event{ @@ -41,10 +40,9 @@ func defineServiceAccountObject(ns, name string) *corev1.ServiceAccount { } } -func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -81,13 +79,12 @@ func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *te } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -116,13 +113,12 @@ func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Inte } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -151,13 +147,12 @@ func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interfa } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -205,13 +200,12 @@ func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) ( } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -235,13 +229,12 @@ func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kuber } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -270,13 +263,12 @@ func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernete } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -300,13 +292,12 @@ func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } -func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient, discovery.DiscoveryInterface) { +func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { realClientset := fake.NewSimpleClientset() - fakeDiscoveryClient := &discoveryfake.FakeDiscovery{Fake: &realClientset.Fake} - fakeDisc := &fakeHappyDiscovery{*fakeDiscoveryClient} + fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) objects := []runtime.Object{} @@ -327,7 +318,7 @@ func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.In } dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, objects...) - return clientset, dynamicClient, fakeDisc + return clientset, dynamicClient } func TestGetUnusedNamespaces(t *testing.T) { @@ -438,9 +429,8 @@ func TestGetUnusedNamespaces(t *testing.T) { GroupBy: "namespace", } - clientset, dynamicClient, discoveryClient := tt.getClientsFunc(ctx, t) - // TODO: EZ: fix fake clients - got, err := GetUnusedNamespaces(ctx, tt.filterOpts, clientset, dynamicClient, discoveryClient, "json", opts) + 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) } From ef5dc18dd501fd8beb63d28639b5ebc50d352fb8 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 5 Oct 2025 20:01:50 +0100 Subject: [PATCH 06/15] remove temp comments --- cmd/kor/namespaces.go | 2 -- pkg/kor/namespaces.go | 1 - 2 files changed, 3 deletions(-) diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go index 1c423680..0766ed8d 100644 --- a/cmd/kor/namespaces.go +++ b/cmd/kor/namespaces.go @@ -19,8 +19,6 @@ var namespaceCmd = &cobra.Command{ ctx := context.Background() clientset := kor.GetKubeClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) - // dicoveryClient := kor.GetDiscoveryClient(kubeconfig) - if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil { fmt.Println(err) } else { diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 427efd5e..61d24ed8 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -280,7 +280,6 @@ func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynami } func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { - // TODO: EZ: func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clientset kubernetes.Interface, dynamicClient dynamic.Interface, discoveryClient discovery.DiscoveryInterface, 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) From cc80570bda80c9cbf29815b85d8687eb0769aa00 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 5 Oct 2025 20:57:42 +0100 Subject: [PATCH 07/15] fix function signature to the project std --- pkg/kor/namespaces.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index 61d24ed8..09b21a62 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -207,10 +207,7 @@ func exceptionsFSWalkFn(path string, d fs.DirEntry, err error) error { return nil } -func loadNamespacedEmbeddedExceptions( - ctx context.Context, - fileSystem embed.FS, -) (*Config, error) { +func loadNamespacedEmbeddedExceptions(ctx context.Context, fileSystem embed.FS) (*Config, error) { if err := fs.WalkDir(exceptionsFS, ".", exceptionsFSWalkFn); err != nil { return nil, err } From 848653ce9872fc0b92ab674762f96134deca7d12 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Mon, 19 Jan 2026 13:06:46 +0000 Subject: [PATCH 08/15] Fix typo --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index d7812496..76c4c0d8 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/yonahd/kor go 1.26.0 -toolchain go1.24.1 - require ( github.com/fatih/color v1.19.0 github.com/jarcoal/httpmock v1.4.1 From 55f8ccea4f4fa818de90c0b19998e21d18261f7b Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 25 Jan 2026 21:21:16 +0000 Subject: [PATCH 09/15] Fix typo --- pkg/kor/namespaces_GetUnusedNamespaces_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index ce7f127a..aac046a4 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -41,6 +41,7 @@ func defineServiceAccountObject(ns, name string) *corev1.ServiceAccount { } func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + //lint:ignore SA1019 fake client is sufficient for unit test realClientset := fake.NewSimpleClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} @@ -83,6 +84,7 @@ func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *te } func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + //lint:ignore SA1019 fake client is sufficient for unit test realClientset := fake.NewSimpleClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} @@ -117,6 +119,7 @@ func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Inte } func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { + //lint:ignore SA1019 fake client is sufficient for unit test realClientset := fake.NewSimpleClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} From 6e60eef792ba7f23699eeaf813da1250c5d756f6 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 25 Jan 2026 21:25:15 +0000 Subject: [PATCH 10/15] Fix typo --- pkg/kor/namespaces_GetUnusedNamespaces_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index aac046a4..2bda186f 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -41,8 +41,7 @@ func defineServiceAccountObject(ns, name string) *corev1.ServiceAccount { } func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - //lint:ignore SA1019 fake client is sufficient for unit test - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -84,8 +83,7 @@ func createEmptyNamespaceWithIgnoredByDefaultResource(ctx context.Context, t *te } func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - //lint:ignore SA1019 fake client is sufficient for unit test - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -119,8 +117,7 @@ func createNonEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Inte } func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - //lint:ignore SA1019 fake client is sufficient for unit test - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) From f37ea752b77a58a6d9f2f6b48859ec7f59ab3397 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 25 Jan 2026 21:32:15 +0000 Subject: [PATCH 11/15] Fix typo --- pkg/kor/namespaces_GetUnusedNamespaces_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/kor/namespaces_GetUnusedNamespaces_test.go b/pkg/kor/namespaces_GetUnusedNamespaces_test.go index 2bda186f..6c0ccacd 100644 --- a/pkg/kor/namespaces_GetUnusedNamespaces_test.go +++ b/pkg/kor/namespaces_GetUnusedNamespaces_test.go @@ -151,7 +151,7 @@ func createEmptyNamespace(ctx context.Context, t *testing.T) (kubernetes.Interfa } func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -204,7 +204,7 @@ func createNonEmptyNamespaceLabeledAsUnused(ctx context.Context, t *testing.T) ( } func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -233,7 +233,7 @@ func createEmptyNamespaceLabeledAsUsed(ctx context.Context, t *testing.T) (kuber } func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -267,7 +267,7 @@ func namespaceWithIgnoredConfigmap(ctx context.Context, t *testing.T) (kubernete } func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) @@ -296,7 +296,7 @@ func createKubeSystemNamespaceWithKorUnusedLabel(ctx context.Context, t *testing } func createKubeSystemNamespace(ctx context.Context, t *testing.T) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} scheme := getNamespaceTestSchema(t) From 2e40a3a635bbdb17f17e283a0acf3c156cd1791c Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sun, 25 Jan 2026 21:33:48 +0000 Subject: [PATCH 12/15] Fix typo --- pkg/kor/namespaces_IsNamespaceUsed_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/kor/namespaces_IsNamespaceUsed_test.go b/pkg/kor/namespaces_IsNamespaceUsed_test.go index 24f8f056..221be187 100644 --- a/pkg/kor/namespaces_IsNamespaceUsed_test.go +++ b/pkg/kor/namespaces_IsNamespaceUsed_test.go @@ -198,7 +198,7 @@ func getNamespaceTestSchema(t *testing.T) *runtime.Scheme { } func createHappyDeployFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} objects := []runtime.Object{} @@ -228,7 +228,7 @@ func createHappyDeployFakeClientInterfaces(ctx context.Context, t *testing.T, ns } func createHappyEmptyFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} objects := []runtime.Object{} @@ -251,7 +251,7 @@ func createHappyEmptyFakeClientInterfaces(ctx context.Context, t *testing.T, ns, } func createUnhappyDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeUnhappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} objects := []runtime.Object{} @@ -274,7 +274,7 @@ func createUnhappyDiscoveryFakeClientInterfaces(ctx context.Context, t *testing. } func createBrokenAPIResourceListDiscoveryFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeBrokenAPIResourceListDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} objects := []runtime.Object{} @@ -297,7 +297,7 @@ func createBrokenAPIResourceListDiscoveryFakeClientInterfaces(ctx context.Contex } func createDynamicDeployListForcedErrorFakeClientInterfaces(ctx context.Context, t *testing.T, ns, name string) (kubernetes.Interface, *dynamicfake.FakeDynamicClient) { - realClientset := fake.NewSimpleClientset() + realClientset := fake.NewClientset() fakeDisc := &fakeHappyDiscovery{discoveryfake.FakeDiscovery{Fake: &realClientset.Fake}} clientset := &fakeClientset{Interface: realClientset, discovery: fakeDisc} objects := []runtime.Object{} From a08e7c2102031c7333140829d003ef1b7f69533b Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sat, 20 Jun 2026 09:18:02 +0100 Subject: [PATCH 13/15] add new param to function --- cmd/kor/namespaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go index 0766ed8d..7e1fe0d2 100644 --- a/cmd/kor/namespaces.go +++ b/cmd/kor/namespaces.go @@ -22,7 +22,7 @@ var namespaceCmd = &cobra.Command{ if response, err := kor.GetUnusedNamespaces(ctx, filterOptions, clientset, dynamicClient, outputFormat, opts); err != nil { fmt.Println(err) } else { - utils.PrintLogo(outputFormat) + utils.PrintLogo(outputFormat, opts.ClusterName) fmt.Println(response) } }, From f27fe2e8303ef4a716506feafbb7987396cd90d8 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sat, 20 Jun 2026 11:01:59 +0100 Subject: [PATCH 14/15] Fix typo --- Makefile | 2 +- go.mod | 2 +- pkg/kor/namespaces.go | 74 ++++++++++------------ pkg/kor/namespaces_IsNamespaceUsed_test.go | 5 +- 4 files changed, 37 insertions(+), 46 deletions(-) diff --git a/Makefile b/Makefile index 11c2fd6b..1e0205d1 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ 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 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/kor/namespaces.go b/pkg/kor/namespaces.go index 09b21a62..af00650e 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -16,7 +16,7 @@ import ( types "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - "k8s.io/utils/strings/slices" + "slices" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" @@ -34,7 +34,6 @@ var ( //go:embed exceptions/namespaces/namespaces.json namespacesConfig []byte - Exclusions []ExceptionNamespacedResource AllNamespacedAPIResourceTypes []*metav1.APIResourceList NamespacedExceptionsConfig *Config ) @@ -54,7 +53,7 @@ func isNamespacedResourceType(resourceType string) bool { return false } -func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *filters.Options) ([]ResourceInfo, error) { +func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, filterOpts *filters.Options, apiResourceLists []*metav1.APIResourceList) ([]ResourceInfo, error) { var unusedNamespaces []ResourceInfo filteredNamespaceNames := filterOpts.Namespaces(clientset) @@ -93,7 +92,7 @@ func processNamespaces(ctx context.Context, clientset kubernetes.Interface, dyna } // skipping default resources here - resourceFound, err := isNamespaceUsed(ctx, clientset, dynamicClient, namespaceName, filterOpts) + resourceFound, err := isNamespaceUsed(ctx, dynamicClient, namespaceName, filterOpts, apiResourceLists) if err != nil { return unusedNamespaces, err } @@ -141,17 +140,23 @@ func ignoreResourceType(resource string, ignoreResourceTypes []string) bool { return false } -func exceptionsFSWalkFn(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } +func loadNamespacedEmbeddedExceptions(ctx context.Context, fileSystem embed.FS) (*Config, error) { + var exclusions []ExceptionNamespacedResource - if d.IsDir() { - return nil - } + 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 + } - // match .json suffix (case-insensitive) - if strings.EqualFold(filepath.Ext(path), ".json") { b, err := exceptionsFS.ReadFile(path) if err != nil { return nil @@ -184,52 +189,37 @@ func exceptionsFSWalkFn(path string, d fs.DirEntry, err error) error { if err := json.Unmarshal(top[key], &items); err != nil { return nil } - if len(items) == 0 { - return nil - } // Keep only items where MatchRegex == true - var kept []ExceptionNamespacedResource for _, it := range items { if it.MatchRegex { - kept = append(kept, it) + it.ResourceType = base + exclusions = append(exclusions, it) } } - if len(kept) == 0 { - return nil - } - // Set ResourceType for each kept item from the file base name - for i := range kept { - kept[i].ResourceType = base - } - Exclusions = append(Exclusions, kept...) + return nil } - return nil -} -func loadNamespacedEmbeddedExceptions(ctx context.Context, fileSystem embed.FS) (*Config, error) { - if err := fs.WalkDir(exceptionsFS, ".", exceptionsFSWalkFn); err != nil { + if err := fs.WalkDir(exceptionsFS, ".", walkFn); err != nil { return nil, err } + // always exclude events - Exclusions = append(Exclusions, ExceptionNamespacedResource{ + exclusions = append(exclusions, ExceptionNamespacedResource{ Namespace: ".*", ResourceName: ".*", ResourceType: "events", MatchRegex: true, - }, - ) - config := &Config{ - ExceptionNamespacedResources: Exclusions, - } - return config, nil + }) + + return &Config{ExceptionNamespacedResources: exclusions}, nil } -func isNamespaceUsed(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options) (bool, error) { +func isNamespaceUsed(ctx context.Context, dynamicClient dynamic.Interface, namespace string, filterOpts *filters.Options, apiResourceLists []*metav1.APIResourceList) (bool, error) { config := NamespacedExceptionsConfig - apiResourceLists, err := clientset.Discovery().ServerPreferredNamespacedResources() - if err != nil { - return true, err + + if apiResourceLists == nil { + return true, nil } // Iterate over all API resources and list instances of each in the specified namespace @@ -289,7 +279,7 @@ func GetUnusedNamespaces(ctx context.Context, filterOpts *filters.Options, clien } resources := make(map[string]map[string][]ResourceInfo) - diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts) + diff, err := processNamespaces(ctx, clientset, dynamicClient, filterOpts, allNamespacedAPIs) if err != nil { fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) } diff --git a/pkg/kor/namespaces_IsNamespaceUsed_test.go b/pkg/kor/namespaces_IsNamespaceUsed_test.go index 221be187..a0994034 100644 --- a/pkg/kor/namespaces_IsNamespaceUsed_test.go +++ b/pkg/kor/namespaces_IsNamespaceUsed_test.go @@ -393,7 +393,7 @@ func TestIsNamespaceUsed(t *testing.T) { }, expectedReturn: true, - expectedError: true, + expectedError: false, }, { name: "imitate broken APIResourceList, error in discovery, ignoring secrets", @@ -445,7 +445,8 @@ func TestIsNamespaceUsed(t *testing.T) { }, } clientset, dynamicClient := tt.getClientsFunc(ctx, t, tt.namespaceName, tt.objName) - got, err := isNamespaceUsed(ctx, clientset, dynamicClient, tt.namespaceName, tt.filterOpts) + 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) } From 5ba575de6f66c2d5b433f5c0b4509955c082f3e9 Mon Sep 17 00:00:00 2001 From: Eriks Zelenka Date: Sat, 20 Jun 2026 14:40:59 +0100 Subject: [PATCH 15/15] Fix typo --- pkg/kor/namespaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go index af00650e..eb053cdf 100644 --- a/pkg/kor/namespaces.go +++ b/pkg/kor/namespaces.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -16,7 +17,6 @@ import ( types "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - "slices" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters"