diff --git a/README.md b/README.md index 941bc527..9ecc773a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - ReplicaSets - DaemonSets - StorageClasses +- NetworkPolicies ![Kor Screenshot](/images/screenshot.png) @@ -116,6 +117,7 @@ Kor provides various subcommands to identify and list unused resources. The avai - `replicaset` - Gets unused replicaSets for the specified namespace or all namespaces. - `daemonset`- Gets unused DaemonSets for the specified namespace or all namespaces. - `finalizer` - Gets unused pending deletion resources for the specified namespace or all namespaces. +- `networkpolicy` - Gets unused NetworkPolicies for the specified namespace or all namespaces. - `exporter` - Export Prometheus metrics. - `version` - Print kor version information. @@ -175,6 +177,7 @@ kor [subcommand] --help | ReplicaSets | replicaSets that specify replicas to 0 and has already completed it's work | | DaemonSets | DaemonSets not scheduled on any nodes | | StorageClasses | StorageClasses not used by any PVs/PVCs | +| NetworkPolicies | NetworkPolicies with no Pods selected | ### Deleting Unused resources diff --git a/charts/kor/Chart.yaml b/charts/kor/Chart.yaml index a2e24f56..4ee3d57b 100644 --- a/charts/kor/Chart.yaml +++ b/charts/kor/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: kor description: A Kubernetes Helm Chart to discover orphaned resources using kor type: application -version: 0.1.7 -appVersion: "0.4.0" +version: 0.1.8 +appVersion: "0.4.3" maintainers: - name: "yonahd" url: "https://github.com/yonahd/kor" diff --git a/charts/kor/README.md b/charts/kor/README.md index f27e61dd..0347a821 100644 --- a/charts/kor/README.md +++ b/charts/kor/README.md @@ -1,6 +1,6 @@ # kor -![Version: 0.1.7](https://img.shields.io/badge/Version-0.1.7-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.0](https://img.shields.io/badge/AppVersion-0.4.0-informational?style=flat-square) +![Version: 0.1.8](https://img.shields.io/badge/Version-0.1.8-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.4.3](https://img.shields.io/badge/AppVersion-0.4.3-informational?style=flat-square) A Kubernetes Helm Chart to discover orphaned resources using kor diff --git a/charts/kor/templates/role.yaml b/charts/kor/templates/role.yaml index e533cc9c..a1d73f12 100644 --- a/charts/kor/templates/role.yaml +++ b/charts/kor/templates/role.yaml @@ -25,6 +25,7 @@ rules: - jobs - replicasets - daemonsets + - networkpolicies verbs: - get - list @@ -56,6 +57,7 @@ rules: - jobs - replicasets - daemonsets + - networkpolicies {{/* cluster-scoped resources */}} - namespaces - clusterroles diff --git a/cmd/kor/networkpolicies.go b/cmd/kor/networkpolicies.go new file mode 100644 index 00000000..cb0efea4 --- /dev/null +++ b/cmd/kor/networkpolicies.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 netpolCmd = &cobra.Command{ + Use: "networkpolicy", + Aliases: []string{"netpol", "networkpolicies"}, + Short: "Gets unused networkpolicies", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + clientset := kor.GetKubeClient(kubeconfig) + if response, err := kor.GetUnusedNetworkPolicies(filterOptions, clientset, outputFormat, opts); err != nil { + fmt.Println(err) + } else { + utils.PrintLogo(outputFormat) + fmt.Println(response) + } + }, +} + +func init() { + rootCmd.AddCommand(netpolCmd) +} diff --git a/pkg/kor/all.go b/pkg/kor/all.go index 58809050..1d8a81da 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -251,6 +251,18 @@ func getUnusedStorageClasses(clientset kubernetes.Interface, filterOpts *filters return allScDiff } +func getUnusedNetworkPolicies(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { + netpolDiff, err := processNamespaceNetworkPolicies(clientset, namespace, filterOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "NetworkPolicies", namespace, err) + } + namespaceNetpolDiff := ResourceDiff{ + "NetworkPolicy", + netpolDiff, + } + return namespaceNetpolDiff +} + func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { resources := make(map[string]map[string][]ResourceInfo) for _, namespace := range filterOpts.Namespaces(clientset) { @@ -272,6 +284,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In resources[namespace]["Job"] = getUnusedJobs(clientset, namespace, filterOpts).diff resources[namespace]["ReplicaSet"] = getUnusedReplicaSets(clientset, namespace, filterOpts).diff resources[namespace]["DaemonSet"] = getUnusedDaemonSets(clientset, namespace, filterOpts).diff + resources[namespace]["NetworkPolicy"] = getUnusedNetworkPolicies(clientset, namespace, filterOpts).diff case "resource": appendResources(resources, "ConfigMap", namespace, getUnusedCMs(clientset, namespace, filterOpts).diff) appendResources(resources, "Service", namespace, getUnusedSVCs(clientset, namespace, filterOpts).diff) @@ -288,6 +301,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In appendResources(resources, "Job", namespace, getUnusedJobs(clientset, namespace, filterOpts).diff) appendResources(resources, "ReplicaSet", namespace, getUnusedReplicaSets(clientset, namespace, filterOpts).diff) appendResources(resources, "DaemonSet", namespace, getUnusedDaemonSets(clientset, namespace, filterOpts).diff) + appendResources(resources, "NetworkPolicy", namespace, getUnusedNetworkPolicies(clientset, namespace, filterOpts).diff) } } diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go index 55a4b7e3..97810901 100644 --- a/pkg/kor/create_test_resources.go +++ b/pkg/kor/create_test_resources.go @@ -398,3 +398,16 @@ func CreateTestUnstructered(kind, apiVersion, namespace, name string) *unstructu }, } } + +func CreateTestNetworkPolicy(name, namespace string, podSelector v1.LabelSelector, labels map[string]string) *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: podSelector, + }, + } +} diff --git a/pkg/kor/delete.go b/pkg/kor/delete.go index 73444a96..bdd49962 100644 --- a/pkg/kor/delete.go +++ b/pkg/kor/delete.go @@ -78,6 +78,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa "StorageClass": func(clientset kubernetes.Interface, namespace, name string) error { return clientset.StorageV1().StorageClasses().Delete(context.TODO(), name, metav1.DeleteOptions{}) }, + "NetworkPolicy": func(clientset kubernetes.Interface, namespace, name string) error { + return clientset.NetworkingV1().NetworkPolicies(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{}) + }, } return deleteResourceApiMap @@ -165,6 +168,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri return clientset.AppsV1().DaemonSets(namespace).Update(context.TODO(), resource.(*appsv1.DaemonSet), metav1.UpdateOptions{}) case "StorageClass": return clientset.StorageV1().StorageClasses().Update(context.TODO(), resource.(*storagev1.StorageClass), metav1.UpdateOptions{}) + case "NetworkPolicy": + return clientset.NetworkingV1().NetworkPolicies(namespace).Update(context.TODO(), resource.(*networkingv1.NetworkPolicy), metav1.UpdateOptions{}) } return nil, fmt.Errorf("resource type '%s' is not supported", resourceType) } @@ -207,6 +212,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour return clientset.AppsV1().DaemonSets(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{}) case "StorageClass": return clientset.StorageV1().StorageClasses().Get(context.TODO(), resourceName, metav1.GetOptions{}) + case "NetworkPolicy": + return clientset.NetworkingV1().NetworkPolicies(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{}) } return nil, fmt.Errorf("resource type '%s' is not supported", resourceType) } diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go index 9a57a1af..b019816d 100644 --- a/pkg/kor/multi.go +++ b/pkg/kor/multi.go @@ -86,6 +86,8 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re diffResult = getUnusedReplicaSets(clientset, namespace, filterOpts) case "ds", "daemonset", "daemonsets": diffResult = getUnusedDaemonSets(clientset, namespace, filterOpts) + case "netpol", "networkpolicy", "networkpolicies": + diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts) default: fmt.Printf("resource type %q is not supported\n", resource) } diff --git a/pkg/kor/networkpolicies.go b/pkg/kor/networkpolicies.go new file mode 100644 index 00000000..251b1f08 --- /dev/null +++ b/pkg/kor/networkpolicies.go @@ -0,0 +1,100 @@ +package kor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/yonahd/kor/pkg/filters" +) + +func processNamespaceNetworkPolicies(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]ResourceInfo, error) { + netpolList, err := clientset.NetworkingV1().NetworkPolicies(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) + if err != nil { + return nil, err + } + + var unusedNetpols []ResourceInfo + + for _, netpol := range netpolList.Items { + if pass, _ := filter.SetObject(&netpol).Run(filterOpts); pass { + continue + } + + if netpol.Labels["kor/used"] == "false" { + reason := "Marked with unused label" + unusedNetpols = append(unusedNetpols, ResourceInfo{Name: netpol.Name, Reason: reason}) + continue + } + + // retrieve pods selected by the NetworkPolicy + labelSelector, err := metav1.LabelSelectorAsSelector(&netpol.Spec.PodSelector) + if err != nil { + return nil, err + } + podList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{ + LabelSelector: labelSelector.String(), + }) + if err != nil { + return nil, err + } + + if len(podList.Items) == 0 { + reason := "NetworkPolicy selects no pods" + unusedNetpols = append(unusedNetpols, ResourceInfo{Name: netpol.Name, Reason: reason}) + } + } + + return unusedNetpols, nil +} + +func GetUnusedNetworkPolicies(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { + resources := make(map[string]map[string][]ResourceInfo) + + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceNetworkPolicies(clientset, namespace, filterOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err) + continue + } + + switch opts.GroupBy { + case "namespace": + resources[namespace] = make(map[string][]ResourceInfo) + resources[namespace]["NetworkPolicy"] = diff + case "resource": + appendResources(resources, "NetworkPolicy", namespace, diff) + } + + if opts.DeleteFlag { + if diff, err := DeleteResource2(diff, clientset, namespace, "NetworkPolicy", opts.NoInteractive); err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete NetworkPolicy %s in namespace %s: %v\n", diff, namespace, 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 + } + } + + unusedNetworkPolicies, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) + if err != nil { + fmt.Printf("err: %v\n", err) + } + + return unusedNetworkPolicies, nil +} diff --git a/pkg/kor/networkpolicies_test.go b/pkg/kor/networkpolicies_test.go new file mode 100644 index 00000000..27ade659 --- /dev/null +++ b/pkg/kor/networkpolicies_test.go @@ -0,0 +1,143 @@ +package kor + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/yonahd/kor/pkg/filters" +) + +func createTestNetworkPolicies(t *testing.T) *fake.Clientset { + clientset := fake.NewSimpleClientset() + + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{Name: testNamespace}, + }, v1.CreateOptions{}) + + if err != nil { + t.Fatalf("Error creating namespace %s: %v", testNamespace, err) + } + + podLabels := map[string]string{ + "app.kubernetes.io/name": "my-app", + "app.kubernetes.io/version": "v1", + "product.my-org/name": "my-app", + } + noMatchLabels := map[string]string{"app.kubernetes.io/version": "v2"} + + pods := []*corev1.Pod{ + CreateTestPod(testNamespace, "pod-1", "", nil, podLabels), + CreateTestPod(testNamespace, "pod-2", "", nil, AppLabels), + } + + for _, pod := range pods { + _, err = clientset.CoreV1().Pods(testNamespace).Create(context.TODO(), pod, v1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating fake pod: %v", err) + } + } + + netpols := []*networkingv1.NetworkPolicy{ + // all pods are selected + CreateTestNetworkPolicy("netpol-1", testNamespace, v1.LabelSelector{}, AppLabels), + CreateTestNetworkPolicy("netpol-2", testNamespace, v1.LabelSelector{}, UsedLabels), + CreateTestNetworkPolicy("netpol-3", testNamespace, v1.LabelSelector{}, UnusedLabels), + // some pods are selected + CreateTestNetworkPolicy("netpol-4", testNamespace, *v1.SetAsLabelSelector(podLabels), AppLabels), + CreateTestNetworkPolicy("netpol-5", testNamespace, *v1.SetAsLabelSelector(podLabels), UnusedLabels), + CreateTestNetworkPolicy("netpol-6", testNamespace, *v1.SetAsLabelSelector(podLabels), UsedLabels), + // no pods are selected + CreateTestNetworkPolicy("netpol-7", testNamespace, *v1.SetAsLabelSelector(noMatchLabels), AppLabels), + CreateTestNetworkPolicy("netpol-8", testNamespace, *v1.SetAsLabelSelector(noMatchLabels), UnusedLabels), + CreateTestNetworkPolicy("netpol-9", testNamespace, *v1.SetAsLabelSelector(noMatchLabels), UsedLabels), + } + + for _, netpol := range netpols { + _, err = clientset.NetworkingV1().NetworkPolicies(netpol.Namespace).Create(context.TODO(), netpol, v1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating fake networkpolicy: %v", err) + } + } + + return clientset +} + +func TestProcessNamespaceNetworkPolicies(t *testing.T) { + clientset := createTestNetworkPolicies(t) + + unusedNetpols, err := processNamespaceNetworkPolicies(clientset, testNamespace, &filters.Options{}) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedUnusedNetpols := []string{ + "netpol-3", + "netpol-5", + "netpol-7", + "netpol-8", + } + + if len(unusedNetpols) != len(expectedUnusedNetpols) { + t.Errorf("Expected %d unused networkpolicies, got %d", len(expectedUnusedNetpols), len(unusedNetpols)) + } + + for i, netpol := range unusedNetpols { + if netpol.Name != expectedUnusedNetpols[i] { + t.Errorf("Expected unused networkpolicy %s, got %s", expectedUnusedNetpols[i], netpol) + } + } +} + +func TestGetUnusedNetworkPolicies(t *testing.T) { + clientset := createTestNetworkPolicies(t) + + opts := Opts{ + WebhookURL: "", + Channel: "", + Token: "", + DeleteFlag: false, + NoInteractive: true, + GroupBy: "namespace", + } + + output, err := GetUnusedNetworkPolicies(&filters.Options{}, clientset, "json", opts) + if err != nil { + t.Fatalf("Error calling GetUnusedNetworkPolicies: %v", err) + } + + expectedOutput := map[string]map[string][]string{ + testNamespace: { + "NetworkPolicy": []string{ + "netpol-3", + "netpol-5", + "netpol-7", + "netpol-8", + }, + }, + } + + var actualOutput map[string]map[string][]string + if err := json.Unmarshal([]byte(output), &actualOutput); err != nil { + t.Fatalf("Error unmarshaling actual output: %v", err) + } + + if !reflect.DeepEqual(expectedOutput, actualOutput) { + t.Errorf("Expected output does not match actual output") + t.Errorf("Expected: %v", expectedOutput) + t.Errorf("Actual: %v", actualOutput) + } +} + +func init() { + scheme.Scheme = runtime.NewScheme() + _ = networkingv1.AddToScheme(scheme.Scheme) +}