Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add clusterrole support #198

Merged
merged 16 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.idea/
dist/**
main.exe
coverage.txt
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
- Deployments
- StatefulSets
- Roles
- ClusterRoles
- HPAs
- PVCs
- Ingresses
Expand Down Expand Up @@ -87,6 +88,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
- `deployments` - Gets unused Deployments for the specified namespace or all namespaces.
- `statefulsets` - Gets unused StatefulSets for the specified namespace or all namespaces.
- `role` - Gets unused Roles for the specified namespace or all namespaces.
- `clusterrole` - Gets unused ClusterRoles for the specified namespace or all namespaces (namespace refers to RoleBinding).
- `hpa` - Gets unused HPAs for the specified namespace or all namespaces.
- `pods` - Gets unused Pods for the specified namespace or all namespaces.
- `pvc` - Gets unused PVCs for the specified namespace or all namespaces.
Expand Down Expand Up @@ -143,14 +145,15 @@ kor [subcommand] --help
| ServiceAccounts | ServiceAccounts unused by Pods<br/>ServiceAccounts unused by roleBinding or clusterRoleBinding | |
| StatefulSets | Statefulsets with no Replicas | |
| Roles | Roles not used in roleBinding | |
| ClusterRoles | ClusterRoles not used in roleBinding or clusterRoleBinding | |
| PVCs | PVCs not used in Pods | |
| Ingresses | Ingresses not pointing at any Service | |
| Hpas | HPAs not used in Deployments<br/> HPAs not used in StatefulSets | |
| CRDs | CRDs not used the cluster | |
| Pvs | PVs not bound to a PVC | |
| Pdbs | PDBs not used in Deployments<br/> PDBs not used in StatefulSets | |
| Jobs | Jobs status is completed | |
| ReplicaSets | replicaSets that specify replicas to 0 and has already completed it's work |
| ReplicaSets | replicaSets that specify replicas to 0 and has already completed it's work |
| DaemonSets | DaemonSets not scheduled on any nodes |

## Deleting Unused resources
Expand Down
30 changes: 30 additions & 0 deletions cmd/kor/clusterroles.go
Original file line number Diff line number Diff line change
@@ -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 clusterRoleCmd = &cobra.Command{
Use: "clusterrole",
Aliases: []string{"clusterroles"},
Short: "Gets unused cluster roles",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
clientset := kor.GetKubeClient(kubeconfig)

if response, err := kor.GetUnusedClusterRoles(filterOptions, clientset, outputFormat, opts); err != nil {
fmt.Println(err)
} else {
utils.PrintLogo(outputFormat)
fmt.Println(response)
}
},
}

func init() {
rootCmd.AddCommand(clusterRoleCmd)
}
15 changes: 15 additions & 0 deletions pkg/kor/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ func getUnusedRoles(clientset kubernetes.Interface, namespace string, filterOpts
return namespaceSADiff
}

func getUnusedClusterRoles(clientset kubernetes.Interface, filterOpts *filters.Options) ResourceDiff {
clusterRoleDiff, err := processClusterRoles(clientset, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "clusterRoles", err)
}
aDiff := ResourceDiff{"ClusterRole", clusterRoleDiff}
return aDiff
}

func getUnusedHpas(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff {
hpaDiff, err := processNamespaceHpas(clientset, namespace, filterOpts)
if err != nil {
Expand Down Expand Up @@ -239,6 +248,12 @@ func GetUnusedAll(filterOpts *filters.Options, clientset kubernetes.Interface, a
outputBuffer.WriteString("\n")
noNamespaceResourceMap[pvDiff.resourceType] = pvDiff.diff

clusterRoleDiff := getUnusedClusterRoles(clientset, filterOpts)
clusterRoleOutput := FormatOutputAll("", []ResourceDiff{clusterRoleDiff}, opts)
outputBuffer.WriteString(clusterRoleOutput)
outputBuffer.WriteString("\n")
noNamespaceResourceMap[clusterRoleDiff.resourceType] = clusterRoleDiff.diff

output := FormatOutputAll("", allDiffs, opts)

outputBuffer.WriteString(output)
Expand Down
148 changes: 148 additions & 0 deletions pkg/kor/clusterroles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package kor

import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/yonahd/kor/pkg/filters"
v1 "k8s.io/api/rbac/v1"
"os"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
)

func retrieveUsedClusterRoles(clientset kubernetes.Interface, filterOpts *filters.Options) ([]string, error) {

//Get a list of all namespaces
namespaceList, err := clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to retrieve namespaces: %v\n", err)
os.Exit(1)
}
roleBindingsAllNameSpaces := make([]v1.RoleBinding, 0)

for _, ns := range namespaceList.Items {
// Get a list of all role bindings in the specified namespace
roleBindings, err := clientset.RbacV1().RoleBindings(ns.Name).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list role bindings in namespace %s: %v", ns.Name, err)
}

roleBindingsAllNameSpaces = append(roleBindingsAllNameSpaces, roleBindings.Items...)
}

usedClusterRoles := make(map[string]bool)

for _, rb := range roleBindingsAllNameSpaces {
if pass, _ := filter.Run(filterOpts); pass {
continue
}
usedClusterRoles[rb.RoleRef.Name] = true
if rb.RoleRef.Kind == "ClusterRole" {
usedClusterRoles[rb.RoleRef.Name] = true
}
}

// Get a list of all cluster role bindings in the specified namespace
clusterRoleBindings, err := clientset.RbacV1().ClusterRoleBindings().List(context.TODO(), metav1.ListOptions{})

if err != nil {
return nil, fmt.Errorf("failed to list cluster role bindings %v", err)
}

for _, crb := range clusterRoleBindings.Items {
if pass, _ := filter.Run(filterOpts); pass {
continue
}
usedClusterRoles[crb.RoleRef.Name] = true

usedClusterRoles[crb.RoleRef.Name] = true
}

var usedClusterRoleNames []string
for role := range usedClusterRoles {
usedClusterRoleNames = append(usedClusterRoleNames, role)
}

return usedClusterRoleNames, nil
}

func retrieveClusterRoleNames(clientset kubernetes.Interface, filterOpts *filters.Options) ([]string, error) {
clusterRoles, err := clientset.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
names := make([]string, 0, len(clusterRoles.Items))
for _, clusterRole := range clusterRoles.Items {
if pass, _ := filter.Run(filterOpts); pass {
continue
}

names = append(names, clusterRole.Name)
}
return names, nil
}

func processClusterRoles(clientset kubernetes.Interface, filterOpts *filters.Options) ([]string, error) {
usedClusterRoles, err := retrieveUsedClusterRoles(clientset, filterOpts)
if err != nil {
return nil, err
}

usedClusterRoles = RemoveDuplicatesAndSort(usedClusterRoles)

clusterRoleNames, err := retrieveClusterRoleNames(clientset, filterOpts)
if err != nil {
return nil, err
}

diff := CalculateResourceDifference(usedClusterRoles, clusterRoleNames)
return diff, nil

}

func GetUnusedClusterRoles(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) {
var outputBuffer bytes.Buffer

response := make(map[string]map[string][]string)

diff, err := processClusterRoles(clientset, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to process cluster role : %v\n", err)
}

if len(diff) > 0 {
// We consider cluster scope resources in "" (empty string) namespace, as it is common in k8s
if response[""] == nil {
response[""] = make(map[string][]string)
}
response[""]["ClusterRoles"] = diff
}

if opts.DeleteFlag {
if diff, err = DeleteResource(diff, clientset, "", "ClusterRole", opts.NoInteractive); err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete clusterRole %s : %v\n", diff, err)
}
}
output := FormatOutput("", diff, "ClusterRoles", opts)
if output != "" {
outputBuffer.WriteString(output)
outputBuffer.WriteString("\n")
response[""]["ClusterRoles"] = diff
}

jsonResponse, err := json.MarshalIndent(response, "", " ")
if err != nil {
return "", err
}

unusedClusterRoles, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
if err != nil {
fmt.Printf("err: %v\n", err)
}

return unusedClusterRoles, nil
}
145 changes: 145 additions & 0 deletions pkg/kor/clusterroles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package kor

import (
"context"
"encoding/json"
"github.com/yonahd/kor/pkg/filters"
"reflect"
"testing"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/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"
)

func createTestClusterRoles(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)
}

clusterRole1 := CreateTestClusterRole("test-clusterRole1")
clusterRole2 := CreateTestClusterRole("test-clusterRole2")
clusterRole3 := CreateTestClusterRole("test-clusterRole3")
_, err = clientset.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole1, v1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating fake %s: %v", "clusterRole", err)
}

_, err = clientset.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole2, v1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating fake %s: %v", "clusterRole", err)
}

_, err = clientset.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole3, v1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating fake %s: %v", "Role", err)
}

testRoleRef2 := CreateTestRoleRefForClusterRole("test-clusterRole2")
testClusterRoleBinding := CreateTestClusterRoleBindingRoleRef(testNamespace, "test-rb2", "test-sa", testRoleRef2)
_, err = clientset.RbacV1().ClusterRoleBindings().Create(context.TODO(), testClusterRoleBinding, v1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating fake %s: %v", "Role", err)
}

testRoleRef3 := CreateTestRoleRefForClusterRole("test-clusterRole3")
testRoleBinding := CreateTestRoleBinding(testNamespace, "test-rb", "test-sa", testRoleRef3)
_, err = clientset.RbacV1().RoleBindings(testNamespace).Create(context.TODO(), testRoleBinding, v1.CreateOptions{})
if err != nil {
t.Fatalf("Error creating fake %s: %v", "clusterRole", err)
}

return clientset
}
func TestRetrieveUsedClusterRoles(t *testing.T) {
clientset := createTestClusterRoles(t)

usedClusterRoles, err := retrieveUsedClusterRoles(clientset, &filters.Options{})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

if len(usedClusterRoles) != 2 {
t.Errorf("Expected 1 used cluster role, got %d", len(usedClusterRoles))
}

expectedRoles := []string{"test-clusterRole1", "test-clusterRole3"}
if reflect.DeepEqual(usedClusterRoles, expectedRoles) {
t.Errorf("Expected 'test-role1', got %s", usedClusterRoles[0])
}
}

func TestRetrieveClusterRoleNames(t *testing.T) {
clientset := createTestClusterRoles(t)
allRoles, err := retrieveClusterRoleNames(clientset, &filters.Options{})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

if len(allRoles) != 3 {
t.Errorf("Expected 2 roles, got %d", len(allRoles))
}
}

func TestProcessClusterRoles(t *testing.T) {
clientset := createTestClusterRoles(t)

unusedClusterRoles, err := processClusterRoles(clientset, &filters.Options{})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

if len(unusedClusterRoles) != 1 {
t.Errorf("Expected 1 unused role, got %d", len(unusedClusterRoles))
}

if unusedClusterRoles[0] != "test-clusterRole1" {
t.Errorf("Expected 'test-clusterRole1', got %s", unusedClusterRoles[0])
}
}

func TestGetUnusedClusterRolesStructured(t *testing.T) {
clientset := createTestClusterRoles(t)

opts := Opts{
WebhookURL: "",
Channel: "",
Token: "",
DeleteFlag: false,
NoInteractive: true,
}

output, err := GetUnusedClusterRoles(&filters.Options{}, clientset, "json", opts)
if err != nil {
t.Fatalf("Error calling GetUnusedRolesStructured: %v", err)
}

expectedOutput := map[string]map[string][]string{
"": {
"ClusterRoles": {"test-clusterRole1"},
},
}

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 \n actualOutput:\n %s \n expectedOutput:\n %s", actualOutput, expectedOutput)
}
}

func init() {
scheme.Scheme = runtime.NewScheme()
_ = appsv1.AddToScheme(scheme.Scheme)
}
Loading
Loading