Skip to content

Commit

Permalink
Feat: Find unused pv (#163)
Browse files Browse the repository at this point in the history
* Feat: discover unused PVs

* support multi and all
  • Loading branch information
yonahd committed Nov 23, 2023
1 parent b7b9b22 commit 7f0367f
Show file tree
Hide file tree
Showing 9 changed files with 280 additions and 19 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
- Ingresses
- PDBs
- CRDs
- PVs

![Kor Screenshot](/images/screenshot.png)

Expand Down Expand Up @@ -82,9 +83,10 @@ Kor provides various subcommands to identify and list unused resources. The avai
- `role` - Gets unused Roles for the specified namespace or all namespaces.
- `hpa` - Gets unused HPAs for the specified namespace or all namespaces.
- `pvc` - Gets unused PVCs for the specified namespace or all namespaces.
- `pv` - Gets unused PVs in the cluster(non namespaced resource).
- `ingress` - Gets unused Ingresses for the specified namespace or all namespaces.
- `pdb` - Gets unused PDBs for the specified namespace or all namespaces.
- `crd` - Gets unused CRDs in the cluster.
- `crd` - Gets unused CRDs in the cluster(non namespaced resource).
- `exporter` - Export Prometheus metrics.

### Supported Flags
Expand Down Expand Up @@ -133,6 +135,7 @@ kor [subcommand] --help
| 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 | |


Expand Down
31 changes: 31 additions & 0 deletions cmd/kor/pv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kor

import (
"fmt"

"github.com/spf13/cobra"
"github.com/yonahd/kor/pkg/kor"
"github.com/yonahd/kor/pkg/utils"
)

var pvCmd = &cobra.Command{
Use: "persistentvolume",
Aliases: []string{"pv", "persistentvolumes"},
Short: "Gets unused pvs",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
clientset := kor.GetKubeClient(kubeconfig)

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

},
}

func init() {
rootCmd.AddCommand(pvCmd)
}
28 changes: 24 additions & 4 deletions pkg/kor/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,21 @@ func getUnusedPdbs(clientset kubernetes.Interface, namespace string, filterOpts
}

func getUnusedCrds(apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface) ResourceDiff {
pdbDiff, err := processCrds(apiExtClient, dynamicClient)
crdDiff, err := processCrds(apiExtClient, dynamicClient)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "Crds", err)
}
namespacePdbDiff := ResourceDiff{"Crd", pdbDiff}
return namespacePdbDiff
allCrdDiff := ResourceDiff{"Crd", crdDiff}
return allCrdDiff
}

func getUnusedPvs(clientset kubernetes.Interface, filterOpts *FilterOptions) ResourceDiff {
pvDiff, err := processPvs(clientset, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "Pvs", err)
}
allPvDiff := ResourceDiff{"Pv", pvDiff}
return allPvDiff
}

func GetUnusedAll(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOptions, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) {
Expand Down Expand Up @@ -173,13 +182,24 @@ func GetUnusedAll(includeExcludeLists IncludeExcludeLists, filterOpts *FilterOpt
}

var allDiffs []ResourceDiff
noNamespaceResourceMap := make(map[string][]string)
crdDiff := getUnusedCrds(apiExtClient, dynamicClient)
allDiffs = append(allDiffs, crdDiff)
crdOutput := FormatOutputAll("", []ResourceDiff{crdDiff}, opts)
outputBuffer.WriteString(crdOutput)
outputBuffer.WriteString("\n")
noNamespaceResourceMap[crdDiff.resourceType] = crdDiff.diff

pvDiff := getUnusedPvs(clientset, filterOpts)
pvOutput := FormatOutputAll("", []ResourceDiff{pvDiff}, opts)
outputBuffer.WriteString(pvOutput)
outputBuffer.WriteString("\n")
noNamespaceResourceMap[pvDiff.resourceType] = pvDiff.diff

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

outputBuffer.WriteString(output)
outputBuffer.WriteString("\n")
response[""] = noNamespaceResourceMap

jsonResponse, err := json.MarshalIndent(response, "", " ")
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions pkg/kor/create_test_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,17 @@ func CreateTestPvc(namespace, name string) *corev1.PersistentVolumeClaim {
}
}

func CreateTestPv(name, phase string) *corev1.PersistentVolume {
return &corev1.PersistentVolume{
ObjectMeta: v1.ObjectMeta{
Name: name,
},
Status: corev1.PersistentVolumeStatus{
Phase: corev1.PersistentVolumePhase(phase),
},
}
}

func CreateTestPdb(namespace, name string, matchLabels map[string]string) *policyv1.PodDisruptionBudget {
return &policyv1.PodDisruptionBudget{
ObjectMeta: v1.ObjectMeta{
Expand Down
7 changes: 7 additions & 0 deletions pkg/kor/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
"ServiceAccount": func(clientset kubernetes.Interface, namespace, name string) error {
return clientset.CoreV1().ServiceAccounts(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
},
"PV": func(clientset kubernetes.Interface, namespace, name string) error {
return clientset.CoreV1().PersistentVolumes().Delete(context.TODO(), name, metav1.DeleteOptions{})
},
}

return deleteResourceApiMap
Expand Down Expand Up @@ -103,6 +106,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
return clientset.AppsV1().StatefulSets(namespace).Update(context.TODO(), resource.(*appsv1.StatefulSet), metav1.UpdateOptions{})
case "ServiceAccount":
return clientset.CoreV1().ServiceAccounts(namespace).Update(context.TODO(), resource.(*corev1.ServiceAccount), metav1.UpdateOptions{})
case "PV":
return clientset.CoreV1().PersistentVolumes().Update(context.TODO(), resource.(*corev1.PersistentVolume), metav1.UpdateOptions{})
}
return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
}
Expand Down Expand Up @@ -131,6 +136,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
return clientset.AppsV1().StatefulSets(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
case "ServiceAccount":
return clientset.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
case "PV":
return clientset.CoreV1().PersistentVolumes().Get(context.TODO(), resourceName, metav1.GetOptions{})
}
return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/kor/kor.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ func FormatOutputAll(namespace string, allDiffs []ResourceDiff, opts Opts) strin

table.Render()
if namespace == "" {
return fmt.Sprintf("Unused CRDs: \n%s", buf.String())
return fmt.Sprintf("Unused %ss: \n%s", allDiffs[0].resourceType, buf.String())
}
return fmt.Sprintf("Unused Resources in Namespace: %s\n%s", namespace, buf.String())
}
Expand Down
47 changes: 34 additions & 13 deletions pkg/kor/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,33 @@ import (
"k8s.io/client-go/kubernetes"
)

func retrieveNoNamespaceDiff(apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts, resourceList []string) ([]ResourceDiff, []string) {
func retrieveNoNamespaceDiff(clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, resourceList []string, filterOpts *FilterOptions) ([]ResourceDiff, []string) {
var noNamespaceDiff []ResourceDiff
markedForRemoval := make([]bool, len(resourceList))
updatedResourceList := resourceList

for counter, resource := range resourceList {
if resource == "crd" || resource == "customresourcedefinition" || resource == "customresourcedefinitions" {
switch resource {
case "crd", "customresourcedefinition", "customresourcedefinitions":
crdDiff := getUnusedCrds(apiExtClient, dynamicClient)
noNamespaceDiff = append(noNamespaceDiff, crdDiff)
updatedResourceList := append(resourceList[:counter], resourceList[counter+1:]...)
return noNamespaceDiff, updatedResourceList
} else {
resourceList[counter] = resource
markedForRemoval[counter] = true
case "pv", "persistentvolume", "persistentvolumes":
pvDiff := getUnusedPvs(clientset, filterOpts)
noNamespaceDiff = append(noNamespaceDiff, pvDiff)
markedForRemoval[counter] = true
}
}

// Remove elements marked for removal
var clearedResourceList []string
for i, marked := range markedForRemoval {
if !marked {
clearedResourceList = append(clearedResourceList, updatedResourceList[i])
}
}
return noNamespaceDiff, resourceList

return noNamespaceDiff, clearedResourceList
}

func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, resourceList []string, filterOpts *FilterOptions) []ResourceDiff {
Expand Down Expand Up @@ -72,14 +85,22 @@ func GetUnusedMulti(includeExcludeLists IncludeExcludeLists, resourceNames strin
response := make(map[string]map[string][]string)
var err error

crdDiff, resourceList := retrieveNoNamespaceDiff(apiExtClient, dynamicClient, outputFormat, opts, resourceList)
if len(crdDiff) != 0 {
output := FormatOutputAll("", crdDiff, opts)
outputBuffer.WriteString(output)
outputBuffer.WriteString("\n")
noNamespaceDiff, resourceList := retrieveNoNamespaceDiff(clientset, apiExtClient, dynamicClient, resourceList, filterOpts)
if len(noNamespaceDiff) != 0 {
for _, diff := range noNamespaceDiff {
if len(diff.diff) != 0 {
output := FormatOutputAll("", []ResourceDiff{diff}, opts)
outputBuffer.WriteString(output)
outputBuffer.WriteString("\n")

resourceMap := make(map[string][]string)
resourceMap[diff.resourceType] = diff.diff
response[""] = resourceMap
}
}

resourceMap := make(map[string][]string)
for _, diff := range crdDiff {
for _, diff := range noNamespaceDiff {
resourceMap[diff.resourceType] = diff.diff
}
response[""] = resourceMap
Expand Down
89 changes: 89 additions & 0 deletions pkg/kor/pv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package kor

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"

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

func processPvs(clientset kubernetes.Interface, filterOpts *FilterOptions) ([]string, error) {
pvs, err := clientset.CoreV1().PersistentVolumes().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}

var unusedPvs []string

for _, pv := range pvs.Items {
if pv.Labels["kor/used"] == "true" {
continue
}

if excluded, _ := HasExcludedLabel(pv.Labels, filterOpts.ExcludeLabels); excluded {
continue
}

if included, _ := HasIncludedAge(pv.CreationTimestamp, filterOpts); !included {
continue
}

if pv.Status.Phase != "Bound" {
unusedPvs = append(unusedPvs, pv.Name)
}

}

return unusedPvs, nil

}

func GetUnusedPvs(filterOpts *FilterOptions, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) {
var outputBuffer bytes.Buffer
response := make(map[string]map[string][]string)

diff, err := processPvs(clientset, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to process pvs: %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[""]["Pv"] = diff
}

if opts.DeleteFlag {
if diff, err = DeleteResource(diff, clientset, "", "PV", opts.NoInteractive); err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete PV %s: %v\n", diff, err)
}
}

output := FormatOutput("", diff, "PVs", opts)
if output != "" {
outputBuffer.WriteString(output)
outputBuffer.WriteString("\n")

response[""]["Pv"] = diff

}

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

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

return unusedPvs, nil
}
Loading

0 comments on commit 7f0367f

Please sign in to comment.