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