From ccae8562dd9a8edefcda7489a4f4264bf653d0ea Mon Sep 17 00:00:00 2001 From: Matan Ryngler Date: Mon, 25 Aug 2025 11:58:50 +0300 Subject: [PATCH] feat: add namespace detection for finding empty namespaces Add functionality to detect unused namespaces that contain no resources (excluding default resources like default service account and kube-root-ca.crt). Changes: - Add pkg/kor/namespaces.go with comprehensive resource counting - Add cmd/kor/namespaces.go for CLI command (kor namespace, kor ns) - Add namespace support to 'kor all' and multi-resource commands - Add comprehensive test suite in pkg/kor/namespaces_test.go - Add exception configuration in pkg/kor/exceptions/namespaces/ - Update pkg/kor/kor.go to include ExceptionNamespaces in Config - Check 15+ resource types including workloads, services, storage, RBAC - Include recent events check to avoid removing actively used namespaces - Skip system namespaces (kube-system, kube-public, kube-node-lease) - Support standard kor filtering options (labels, age, exceptions) Resolves #92 --- cmd/kor/namespaces.go | 30 ++ pkg/kor/all.go | 14 + pkg/kor/exceptions/namespaces/namespaces.json | 3 + pkg/kor/kor.go | 1 + pkg/kor/multi.go | 4 + pkg/kor/namespaces.go | 334 ++++++++++++++++++ pkg/kor/namespaces_test.go | 192 ++++++++++ 7 files changed, 578 insertions(+) create mode 100644 cmd/kor/namespaces.go create mode 100644 pkg/kor/exceptions/namespaces/namespaces.json create mode 100644 pkg/kor/namespaces.go create mode 100644 pkg/kor/namespaces_test.go diff --git a/cmd/kor/namespaces.go b/cmd/kor/namespaces.go new file mode 100644 index 00000000..2d71e0b1 --- /dev/null +++ b/cmd/kor/namespaces.go @@ -0,0 +1,30 @@ +package kor + +import ( + "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) { + clientset := kor.GetKubeClient(kubeconfig) + if response, err := kor.GetUnusedNamespaces(filterOptions, clientset, outputFormat, opts); err != nil { + fmt.Println(err) + } else { + utils.PrintLogo(outputFormat) + fmt.Println(response) + } + }, +} + +func init() { + rootCmd.AddCommand(namespaceCmd) +} diff --git a/pkg/kor/all.go b/pkg/kor/all.go index ab1ddbae..7ada8a10 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -302,6 +302,18 @@ func getUnusedRoleBindings(clientset kubernetes.Interface, namespace string, fil return namespaceRoleBindingDiff } +func getUnusedNamespaces(clientset kubernetes.Interface, filterOpts *filters.Options, opts common.Opts) ResourceDiff { + namespaceDiff, err := processNamespaces(clientset, filterOpts, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "namespaces", err) + } + allNamespaceDiff := ResourceDiff{ + "Namespace", + namespaceDiff, + } + return allNamespaceDiff +} + func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) { resources := make(map[string]map[string][]ResourceInfo) for _, namespace := range filterOpts.Namespaces(clientset) { @@ -377,6 +389,7 @@ func GetUnusedAllNonNamespaced(filterOpts *filters.Options, clientset kubernetes resources[""]["ClusterRoleBinding"] = getUnusedClusterRoleBindings(clientset, filterOpts, opts).diff resources[""]["StorageClass"] = getUnusedStorageClasses(clientset, filterOpts).diff resources[""]["VolumeAttachment"] = getUnusedVolumeAttachments(clientset, filterOpts).diff + resources[""]["Namespace"] = getUnusedNamespaces(clientset, filterOpts, opts).diff case "resource": appendResources(resources, "Crd", "", getUnusedCrds(apiExtClient, dynamicClient, filterOpts).diff) appendResources(resources, "Pv", "", getUnusedPvs(clientset, filterOpts).diff) @@ -384,6 +397,7 @@ func GetUnusedAllNonNamespaced(filterOpts *filters.Options, clientset kubernetes appendResources(resources, "ClusterRoleBinding", "", getUnusedClusterRoleBindings(clientset, filterOpts, opts).diff) appendResources(resources, "StorageClass", "", getUnusedStorageClasses(clientset, filterOpts).diff) appendResources(resources, "VolumeAttachment", "", getUnusedVolumeAttachments(clientset, filterOpts).diff) + appendResources(resources, "Namespace", "", getUnusedNamespaces(clientset, filterOpts, opts).diff) } diff --git a/pkg/kor/exceptions/namespaces/namespaces.json b/pkg/kor/exceptions/namespaces/namespaces.json new file mode 100644 index 00000000..712c3ea6 --- /dev/null +++ b/pkg/kor/exceptions/namespaces/namespaces.json @@ -0,0 +1,3 @@ +{ + "exceptionNamespaces": [] +} \ No newline at end of file diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go index fc8ad38e..845dd185 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -46,6 +46,7 @@ type Config struct { ExceptionJobs []ExceptionResource `json:"exceptionJobs"` ExceptionPdbs []ExceptionResource `json:"exceptionPdbs"` ExceptionRoleBindings []ExceptionResource `json:"exceptionRoleBindings"` + ExceptionNamespaces []ExceptionResource `json:"exceptionNamespaces"` // Add other configurations if needed } diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go index f3dbfbad..4691cb5b 100644 --- a/pkg/kor/multi.go +++ b/pkg/kor/multi.go @@ -68,6 +68,10 @@ func retrieveNoNamespaceDiff(clientset kubernetes.Interface, apiExtClient apiext vattsDiff := getUnusedVolumeAttachments(clientset, filterOpts) noNamespaceDiff = append(noNamespaceDiff, vattsDiff) markedForRemoval[counter] = true + case "namespace": + namespaceDiff := getUnusedNamespaces(clientset, filterOpts, opts) + noNamespaceDiff = append(noNamespaceDiff, namespaceDiff) + markedForRemoval[counter] = true } } diff --git a/pkg/kor/namespaces.go b/pkg/kor/namespaces.go new file mode 100644 index 00000000..36d95bfa --- /dev/null +++ b/pkg/kor/namespaces.go @@ -0,0 +1,334 @@ +package kor + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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 + +// isDefaultResource checks if a resource is one of the default resources +// that are automatically created in every namespace +func isDefaultResource(resourceType, name string) bool { + // Default service account + if resourceType == "serviceaccount" && name == "default" { + return true + } + + // Default CA configmap + if resourceType == "configmap" && name == "kube-root-ca.crt" { + return true + } + + // Default service account token secret (for older Kubernetes versions) + if resourceType == "secret" && name == "default-token-" { + return true + } + + return false +} + +// countResourcesInNamespace counts non-default resources in a namespace +func countResourcesInNamespace(clientset kubernetes.Interface, namespace string) (int, error) { + totalCount := 0 + + // Count ConfigMaps (excluding default ones) + configmaps, err := clientset.CoreV1().ConfigMaps(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + for _, cm := range configmaps.Items { + if !isDefaultResource("configmap", cm.Name) { + totalCount++ + } + } + + // Count Secrets (excluding default ones) + secrets, err := clientset.CoreV1().Secrets(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + for _, secret := range secrets.Items { + if !isDefaultResource("secret", secret.Name) { + totalCount++ + } + } + + // Count ServiceAccounts (excluding default one) + serviceAccounts, err := clientset.CoreV1().ServiceAccounts(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + for _, sa := range serviceAccounts.Items { + if !isDefaultResource("serviceaccount", sa.Name) { + totalCount++ + } + } + + // Count Services + services, err := clientset.CoreV1().Services(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(services.Items) + + // Count Pods + pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(pods.Items) + + // Count Deployments + deployments, err := clientset.AppsV1().Deployments(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(deployments.Items) + + // Count StatefulSets + statefulSets, err := clientset.AppsV1().StatefulSets(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(statefulSets.Items) + + // Count DaemonSets + daemonSets, err := clientset.AppsV1().DaemonSets(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(daemonSets.Items) + + // Count ReplicaSets + replicaSets, err := clientset.AppsV1().ReplicaSets(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(replicaSets.Items) + + // Count Jobs + jobs, err := clientset.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(jobs.Items) + + // Count CronJobs + cronJobs, err := clientset.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(cronJobs.Items) + + // Count PersistentVolumeClaims + pvcs, err := clientset.CoreV1().PersistentVolumeClaims(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(pvcs.Items) + + // Count Ingresses + ingresses, err := clientset.NetworkingV1().Ingresses(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(ingresses.Items) + + // Count NetworkPolicies + networkPolicies, err := clientset.NetworkingV1().NetworkPolicies(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(networkPolicies.Items) + + // Count Roles + roles, err := clientset.RbacV1().Roles(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(roles.Items) + + // Count RoleBindings + roleBindings, err := clientset.RbacV1().RoleBindings(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(roleBindings.Items) + + // Count PodDisruptionBudgets + pdbs, err := clientset.PolicyV1().PodDisruptionBudgets(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(pdbs.Items) + + // Count HorizontalPodAutoscalers + hpas, err := clientset.AutoscalingV1().HorizontalPodAutoscalers(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(hpas.Items) + + // Count ResourceQuotas + resourceQuotas, err := clientset.CoreV1().ResourceQuotas(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(resourceQuotas.Items) + + // Count LimitRanges + limitRanges, err := clientset.CoreV1().LimitRanges(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(limitRanges.Items) + + // Count Endpoints + endpoints, err := clientset.CoreV1().Endpoints(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(endpoints.Items) + + // Count EndpointSlices + endpointSlices, err := clientset.DiscoveryV1().EndpointSlices(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + totalCount += len(endpointSlices.Items) + + // Count Events (recent activity indicator) + events, err := clientset.CoreV1().Events(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return 0, err + } + // Count events from the last hour as "recent activity" + recentEvents := 0 + for _, event := range events.Items { + if event.LastTimestamp.Time.After(time.Now().Add(-time.Hour)) { + recentEvents++ + } + } + totalCount += recentEvents + + return totalCount, nil +} + +func processNamespaces(clientset kubernetes.Interface, filterOpts *filters.Options, opts common.Opts) ([]ResourceInfo, error) { + // Get all namespaces + namespaceList, err := clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) + if err != nil { + return nil, err + } + + config, err := unmarshalConfig(namespacesConfig) + if err != nil { + return nil, err + } + + var unusedNamespaces []ResourceInfo + + for _, ns := range namespaceList.Items { + // Skip system namespaces by default unless explicitly included + if ns.Name == "kube-system" || ns.Name == "kube-public" || ns.Name == "kube-node-lease" { + continue + } + + // Skip resources with ownerReferences if the general flag is set + if filterOpts.IgnoreOwnerReferences && len(ns.OwnerReferences) > 0 { + continue + } + + if pass, _ := filter.SetObject(&ns).Run(filterOpts); pass { + continue + } + + if ns.Labels["kor/used"] == "false" { + reason := "Marked with unused label" + unusedNamespaces = append(unusedNamespaces, ResourceInfo{Name: ns.Name, Reason: reason}) + continue + } + + // Check for exception + exceptionFound, err := isResourceException(ns.Name, "", config.ExceptionNamespaces) + if err != nil { + return nil, err + } + + if exceptionFound { + continue + } + + // Count resources in the namespace + resourceCount, err := countResourcesInNamespace(clientset, ns.Name) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to count resources in namespace %s: %v\n", ns.Name, err) + continue + } + + // If namespace has no resources (or only default resources), it's unused + if resourceCount == 0 { + reason := "Namespace contains no resources (excluding default resources)" + unusedNamespaces = append(unusedNamespaces, ResourceInfo{Name: ns.Name, Reason: reason}) + } + } + + if opts.DeleteFlag { + if unusedNamespaces, err = DeleteResource(unusedNamespaces, clientset, "", "Namespace", opts.NoInteractive); err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete Namespace %s: %v\n", unusedNamespaces, err) + } + } + + return unusedNamespaces, nil +} + +func GetUnusedNamespaces(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) { + resources := make(map[string]map[string][]ResourceInfo) + + diff, err := processNamespaces(clientset, filterOpts, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespaces: %v\n", err) + return "", err + } + + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]ResourceInfo) + resources[""]["Namespace"] = diff + case "resource": + appendResources(resources, "Namespace", "", diff) + } + + 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_test.go b/pkg/kor/namespaces_test.go new file mode 100644 index 00000000..a6205255 --- /dev/null +++ b/pkg/kor/namespaces_test.go @@ -0,0 +1,192 @@ +package kor + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/yonahd/kor/pkg/common" + "github.com/yonahd/kor/pkg/filters" +) + +func createTestNamespaces(t *testing.T) *fake.Clientset { + clientset := fake.NewSimpleClientset() + + // Create test namespace 1 - empty except for default resources + emptyNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-namespace", Labels: AppLabels}, + } + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), emptyNamespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating empty namespace: %v", err) + } + + // Create default service account in empty namespace + defaultSA := CreateTestServiceAccount("empty-namespace", "default", AppLabels) + _, err = clientset.CoreV1().ServiceAccounts("empty-namespace").Create(context.TODO(), defaultSA, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating default service account: %v", err) + } + + // Create kube-root-ca.crt configmap in empty namespace + defaultCM := CreateTestConfigmap("empty-namespace", "kube-root-ca.crt", AppLabels) + _, err = clientset.CoreV1().ConfigMaps("empty-namespace").Create(context.TODO(), defaultCM, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating default configmap: %v", err) + } + + // Create test namespace 2 - has non-default resources + nonEmptyNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "non-empty-namespace", Labels: AppLabels}, + } + _, err = clientset.CoreV1().Namespaces().Create(context.TODO(), nonEmptyNamespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating non-empty namespace: %v", err) + } + + // Create a custom service in non-empty namespace + customService := CreateTestService("non-empty-namespace", "custom-service") + _, err = clientset.CoreV1().Services("non-empty-namespace").Create(context.TODO(), customService, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating custom service: %v", err) + } + + // Create test namespace 3 - marked as used + usedNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "used-namespace", Labels: UsedLabels}, + } + _, err = clientset.CoreV1().Namespaces().Create(context.TODO(), usedNamespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating used namespace: %v", err) + } + + // Create test namespace 4 - marked as unused + unusedNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "unused-namespace", Labels: UnusedLabels}, + } + _, err = clientset.CoreV1().Namespaces().Create(context.TODO(), unusedNamespace, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating unused namespace: %v", err) + } + + return clientset +} + +func TestRetrieveNamespaceNames(t *testing.T) { + clientset := createTestNamespaces(t) + + namespaceNames, err := clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(namespaceNames.Items) != 4 { + t.Errorf("Expected 4 namespaces, got %d", len(namespaceNames.Items)) + } + + expectedNamespaces := map[string]bool{"empty-namespace": true, "non-empty-namespace": true, "used-namespace": true, "unused-namespace": true} + for _, ns := range namespaceNames.Items { + if !expectedNamespaces[ns.Name] { + t.Errorf("Unexpected namespace name: %s", ns.Name) + } + } +} + +func TestCountResourcesInNamespace(t *testing.T) { + clientset := createTestNamespaces(t) + + // Test empty namespace - should return 0 (default resources are excluded) + emptyCount, err := countResourcesInNamespace(clientset, "empty-namespace") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if emptyCount != 0 { + t.Errorf("Expected 0 resources in empty namespace, got %d", emptyCount) + } + + // Test non-empty namespace - should return 1 (the custom service) + nonEmptyCount, err := countResourcesInNamespace(clientset, "non-empty-namespace") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if nonEmptyCount != 1 { + t.Errorf("Expected 1 resource in non-empty namespace, got %d", nonEmptyCount) + } +} + +func TestProcessNamespaces(t *testing.T) { + clientset := createTestNamespaces(t) + + opts := common.Opts{ + WebhookURL: "", + Channel: "", + Token: "", + DeleteFlag: false, + NoInteractive: true, + GroupBy: "namespace", + } + + filterOpts := &filters.Options{} + + output, err := processNamespaces(clientset, filterOpts, opts) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Should find 2 unused namespaces: empty-namespace and unused-namespace + expectedNamespaces := []string{"empty-namespace", "unused-namespace"} + if len(output) != 2 { + t.Fatalf("Expected 2 unused namespaces, got %d", len(output)) + } + + for i, ns := range output { + if ns.Name != expectedNamespaces[i] { + t.Errorf("Expected unused namespace %s, got %s", expectedNamespaces[i], ns.Name) + } + } +} + +func TestGetUnusedNamespaces(t *testing.T) { + clientset := createTestNamespaces(t) + + opts := common.Opts{ + WebhookURL: "", + Channel: "", + Token: "", + DeleteFlag: false, + NoInteractive: true, + GroupBy: "namespace", + } + + filterOpts := &filters.Options{} + + output, err := GetUnusedNamespaces(filterOpts, clientset, "json", opts) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + var result map[string]map[string][]string + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("Failed to unmarshal JSON output: %v", err) + } + + expectedOutput := map[string]map[string][]string{ + "": { + "Namespace": { + "empty-namespace", + "unused-namespace", + }, + }, + } + + if !reflect.DeepEqual(result, expectedOutput) { + t.Errorf("Expected output does not match") + t.Errorf("Expected: %+v", expectedOutput) + t.Errorf("Got: %+v", result) + } +}