diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cfe09a0b..7e848c45 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -Please read [CONTRIBUTING.md](CONTRIBUTING.md) for additional information on contributing to this repository! + -## What this PR does / why we need it +## What this PR does / why we need it? ## PR Checklist - [ ] This PR adds K8s exceptions (false positives) - [ ] This PR adds new code -- [ ] This PR includes test for any new code +- [ ] This PR includes tests for new/existing code +- [ ] This PR adds docs + -## Github Issue +## GitHub Issue [XX-XX] diff --git a/README.md b/README.md index 06e531d0..57010692 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,11 @@ Kor provides various subcommands to identify and list unused resources. The avai ``` --delete Delete unused resources - -l, --exclude-labels string Selector to filter out, Example: --exclude-labels key1=value1,key2=value2. If --include-labels is set, --exclude-labels will be ignored. - --exclude-namespaces strings Namespaces to be excluded, split by commas. Example: --exclude-namespaces ns1,ns2,ns3. If --include-namespaces is set, --exclude-namespaces will be ignored. + -l, --exclude-labels strings Selector to filter out, Example: --exclude-labels key1=value1,key2=value2. If --include-labels is set, --exclude-labels will be ignored. + -e, --exclude-namespaces strings Namespaces to be excluded, split by commas. Example: --exclude-namespaces ns1,ns2,ns3. If --include-namespaces is set, --exclude-namespaces will be ignored. + --group-by string Group output by (namespace, resource) (default "namespace") -h, --help help for kor - --include-labels string Selector to filter in, Example: --include-labels key1=value1,key2=value2. + --include-labels string Selector to filter in, Example: --include-labels key1=value1.(currently supports one label) -n, --include-namespaces strings Namespaces to run on, split by commas. Example: --include-namespaces ns1,ns2,ns3. If set, non-namespaced resources will be ignored. -k, --kubeconfig string Path to kubeconfig file (optional) --newer-than string The maximum age of the resources to be considered unused. This flag cannot be used together with older-than flag. Example: --newer-than=1h2m @@ -151,7 +152,7 @@ For more information about each subcommand and its available flags, you can use kor [subcommand] --help ``` -## Supported resources and limitations +### Supported resources and limitations | Resource | What it looks for | Known False Positives ⚠️ | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -169,12 +170,12 @@ kor [subcommand] --help | CRDs | CRDs not used the cluster | | | Pvs | PVs not bound to a PVC | | | Pdbs | PDBs not used in Deployments
PDBs not used in StatefulSets | | -| Jobs | Jobs status is completed | | +| Jobs | Jobs status is completed
Jobs failed with no retries left | | | 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 | -## Deleting Unused resources +### Deleting Unused resources If you want to delete resources in an interactive way using Kor you can run: @@ -194,7 +195,7 @@ To delete with no prompt ( ⚠️ use with caution): kor configmap --include-namespaces my-namespace --delete --no-interactive ``` -## Ignore Resources +### Ignore Resources The resources labeled with: @@ -204,7 +205,7 @@ kor/used=true Will be ignored by kor even if they are unused. You can add this label to resources you want to ignore. -## Force clean Resources +### Force clean Resources The resources labeled with: @@ -214,6 +215,66 @@ kor/used=false Will be cleaned always. This is a good way to mark resources for later cleanup. +### Output Formats + +Kor supports three output formats: `table`, `json`, and `yaml`. The default output format is `table`. +Additionally, you can use the `--group-by` flag to group the output by `namespace` or `resource`. + +#### Group by resource + +```sh +kor all --group-by=resource --output=table +``` +``` +Unused ConfigMaps: ++---+-----------+---------------+ +| # | NAMESPACE | RESOURCE NAME | ++---+-----------+---------------+ +| 1 | ns1 | cm1 | +| 2 | ns1 | cm2 | +| 3 | ns2 | cm3 | ++---+-----------+---------------+ +Unused Deployments: ++---+-----------+---------------+ +| # | NAMESPACE | RESOURCE NAME | ++---+-----------+---------------+ +| 1 | ns1 | deploy1 | +| 2 | ns2 | deploy2 | ++---+-----------+---------------+ +Unused ReplicaSets: ++---+-----------+--------------------+ +| # | NAMESPACE | RESOURCE NAME | ++---+-----------+--------------------+ +| 1 | ns1 | deploy1-654d48b75f | +| 2 | ns2 | deploy2-79f48888c6 | ++---+-----------+--------------------+ +``` + +#### Group by namespace + +```sh +kor all --group-by=namespace --output=table +``` +``` +Unused resources in namespace: "ns1" ++---+---------------+--------------------+ +| # | RESOURCE TYPE | RESOURCE NAME | ++---+---------------+--------------------+ +| 1 | ConfigMap | cm1 | +| 2 | ConfigMap | cm2 | +| 3 | ReplicaSet | deploy1-654d48b75f | +| 4 | Deployment | deploy1 | ++---+---------------+--------------------+ +Unused resources in namespace: "ns2" ++---+---------------+--------------------+ +| # | RESOURCE TYPE | RESOURCE NAME | ++---+---------------+--------------------+ +| 1 | ReplicaSet | deploy2-79f48888c6 | +| 2 | ConfigMap | cm3 | +| 3 | Deployment | deploy2 | ++---+---------------+--------------------+ +``` + ## In Cluster Usage To use this tool inside the cluster running as a CronJob and sending the results to a Slack Webhook as raw text(has characters limits of 4000) or to a Slack channel by uploading a file(recommended), you can use the following commands: diff --git a/charts/kor/Chart.yaml b/charts/kor/Chart.yaml index 0b28be06..0e91b296 100644 --- a/charts/kor/Chart.yaml +++ b/charts/kor/Chart.yaml @@ -1,24 +1,6 @@ apiVersion: v2 name: kor -description: A Helm chart for Kubernetes - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. +description: A Kubernetes Helm Chart to discover orphaned resources using kor type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.4 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "0.3.4" +version: 0.1.6 +appVersion: "0.4.0" diff --git a/charts/kor/README.md b/charts/kor/README.md index a13219e5..8808b803 100644 --- a/charts/kor/README.md +++ b/charts/kor/README.md @@ -1,13 +1,14 @@ # kor -![Version: 0.1.4](https://img.shields.io/badge/Version-0.1.4-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.3.4](https://img.shields.io/badge/AppVersion-0.3.4-informational?style=flat-square) +![Version: 0.1.6](https://img.shields.io/badge/Version-0.1.6-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) -A Helm chart for Kubernetes +A Kubernetes Helm Chart to discover orphaned resources using kor ## Values | Key | Type | Default | Description | |-----|------|---------|-------------| +| additionalLabels | object | `{}` | Custom labels to add into metadata | | cronJob.args[0] | string | `"all"` | | | cronJob.command[0] | string | `"kor"` | | | cronJob.enabled | bool | `false` | | @@ -41,17 +42,17 @@ A Helm chart for Kubernetes | prometheusExporter.service.port | int | `8080` | | | prometheusExporter.service.type | string | `"ClusterIP"` | | | prometheusExporter.serviceMonitor.enabled | bool | `true` | | -| prometheusExporter.serviceMonitor.interval | string | `"30s"` | | -| prometheusExporter.serviceMonitor.labels | object | `{}` | | +| prometheusExporter.serviceMonitor.interval | string | `"30s"` | Set how frequently Prometheus should scrape | +| prometheusExporter.serviceMonitor.labels | object | `{}` | Service monitor labels | | prometheusExporter.serviceMonitor.metricRelabelings | list | `[]` | | -| prometheusExporter.serviceMonitor.namespace | string | `""` | | +| prometheusExporter.serviceMonitor.namespace | string | `""` | Set the namespace the ServiceMonitor should be deployed, if empty namespace will be `.Release.Namespace` | | prometheusExporter.serviceMonitor.relabelings | list | `[]` | | -| prometheusExporter.serviceMonitor.targetLabels | list | `[]` | | +| prometheusExporter.serviceMonitor.targetLabels | list | `[]` | Set of labels to transfer on the Kubernetes Service onto the target. | | prometheusExporter.serviceMonitor.telemetryPath | string | `"/metrics"` | | -| prometheusExporter.serviceMonitor.timeout | string | `"10s"` | | -| serviceAccount.annotations | object | `{}` | | -| serviceAccount.create | bool | `true` | | -| serviceAccount.name | string | `""` | | +| prometheusExporter.serviceMonitor.timeout | string | `"10s"` | Set timeout for scrape | +| serviceAccount.annotations | object | `{}` | Annotations to add to the service account | +| serviceAccount.create | bool | `true` | Specifies whether a service account should be created | +| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template | ---------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.12.0](https://github.com/norwoodj/helm-docs/releases/v1.12.0) +Autogenerated from chart metadata using [helm-docs v1.13.1](https://github.com/norwoodj/helm-docs/releases/v1.13.1) diff --git a/charts/kor/templates/_helpers.tpl b/charts/kor/templates/_helpers.tpl index 5106eec9..d33123b0 100644 --- a/charts/kor/templates/_helpers.tpl +++ b/charts/kor/templates/_helpers.tpl @@ -51,6 +51,9 @@ helm.sh/chart: {{ include "kor.chart" . }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- with .Values.additionalLabels }} +{{ toYaml . }} +{{- end }} {{- end }} {{/* diff --git a/charts/kor/templates/cronjob.yaml b/charts/kor/templates/cronjob.yaml index 596ef272..273f191e 100644 --- a/charts/kor/templates/cronjob.yaml +++ b/charts/kor/templates/cronjob.yaml @@ -12,6 +12,7 @@ spec: spec: template: metadata: + {{- include "kor.labels" . | nindent 8 }} name: {{ .Release.Name }} spec: serviceAccountName: {{ include "kor.serviceAccountName" . }} diff --git a/charts/kor/templates/deployment.yaml b/charts/kor/templates/deployment.yaml index 9e140525..b1566bfa 100644 --- a/charts/kor/templates/deployment.yaml +++ b/charts/kor/templates/deployment.yaml @@ -18,6 +18,7 @@ spec: template: metadata: labels: + {{- include "kor.labels" . | nindent 8 }} app: {{ .Values.prometheusExporter.name }} spec: serviceAccountName: {{ include "kor.serviceAccountName" . }} diff --git a/charts/kor/values.yaml b/charts/kor/values.yaml index 09091f24..4a251c0b 100644 --- a/charts/kor/values.yaml +++ b/charts/kor/values.yaml @@ -61,25 +61,28 @@ prometheusExporter: serviceMonitor: enabled: true - # Set the namespace the ServiceMonitor should be deployed, if empty namespace will be .Release.Namespace + # -- Set the namespace the ServiceMonitor should be deployed, if empty namespace will be `.Release.Namespace` namespace: "" - # Service monitor labels + # -- Service monitor labels labels: {} - # Set how frequently Prometheus should scrape + # -- Set how frequently Prometheus should scrape interval: 30s telemetryPath: /metrics - # Set timeout for scrape + # -- Set timeout for scrape timeout: 10s relabelings: [] - # Set of labels to transfer on the Kubernetes Service onto the target. + # -- Set of labels to transfer on the Kubernetes Service onto the target. targetLabels: [] metricRelabelings: [] +# -- Custom labels to add into metadata +additionalLabels: {} + serviceAccount: - # Specifies whether a service account should be created + # -- Specifies whether a service account should be created create: true - # Annotations to add to the service account + # -- Annotations to add to the service account annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template + # -- The name of the service account to use. + # -- If not set and create is true, a name is generated using the fullname template name: "" diff --git a/cmd/kor/root.go b/cmd/kor/root.go index 44285fcd..bcc2c98d 100644 --- a/cmd/kor/root.go +++ b/cmd/kor/root.go @@ -59,6 +59,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&opts.DeleteFlag, "delete", false, "Delete unused resources") rootCmd.PersistentFlags().BoolVar(&opts.NoInteractive, "no-interactive", false, "Do not prompt for confirmation when deleting resources. Be careful using this flag!") rootCmd.PersistentFlags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Verbose output (print empty namespaces)") + rootCmd.PersistentFlags().StringVar(&opts.GroupBy, "group-by", "namespace", "Group output by (namespace, resource)") addFilterOptionsFlag(rootCmd, filterOptions) } @@ -80,6 +81,6 @@ func addFilterOptionsFlag(cmd *cobra.Command, opts *filters.Options) { cmd.PersistentFlags().StringVar(&opts.NewerThan, "newer-than", opts.NewerThan, "The maximum age of the resources to be considered unused. This flag cannot be used together with older-than flag. Example: --newer-than=1h2m") cmd.PersistentFlags().StringVar(&opts.OlderThan, "older-than", opts.OlderThan, "The minimum age of the resources to be considered unused. This flag cannot be used together with newer-than flag. Example: --older-than=1h2m") cmd.PersistentFlags().StringVar(&opts.IncludeLabels, "include-labels", opts.IncludeLabels, "Selector to filter in, Example: --include-labels key1=value1.(currently supports one label)") - cmd.PersistentFlags().StringSliceVar(&opts.ExcludeNamespaces, "exclude-namespaces", opts.ExcludeNamespaces, "Namespaces to be excluded, split by commas. Example: --exclude-namespaces ns1,ns2,ns3. If --include-namespaces is set, --exclude-namespaces will be ignored.") + cmd.PersistentFlags().StringSliceVarP(&opts.ExcludeNamespaces, "exclude-namespaces", "e", opts.ExcludeNamespaces, "Namespaces to be excluded, split by commas. Example: --exclude-namespaces ns1,ns2,ns3. If --include-namespaces is set, --exclude-namespaces will be ignored.") cmd.PersistentFlags().StringSliceVarP(&opts.IncludeNamespaces, "include-namespaces", "n", opts.IncludeNamespaces, "Namespaces to run on, split by commas. Example: --include-namespaces ns1,ns2,ns3. If set, non-namespaced resources will be ignored.") } diff --git a/go.mod b/go.mod index 7f90491a..46798db5 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,14 @@ go 1.22.0 toolchain go1.22.2 require ( - github.com/fatih/color v1.16.0 + github.com/fatih/color v1.17.0 github.com/olekukonko/tablewriter v0.0.5 - github.com/prometheus/client_golang v1.19.0 + github.com/prometheus/client_golang v1.19.1 github.com/spf13/cobra v1.8.0 - k8s.io/api v0.30.0 - k8s.io/apiextensions-apiserver v0.30.0 - k8s.io/apimachinery v0.30.0 - k8s.io/client-go v0.30.0 + k8s.io/api v0.30.1 + k8s.io/apiextensions-apiserver v0.30.1 + k8s.io/apimachinery v0.30.1 + k8s.io/client-go v0.30.1 k8s.io/utils v0.0.0-20230726121419-3b25d923346b sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index 67802709..1625cc8a 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -86,8 +86,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= @@ -173,14 +173,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.30.0 h1:siWhRq7cNjy2iHssOB9SCGNCl2spiF1dO3dABqZ8niA= -k8s.io/api v0.30.0/go.mod h1:OPlaYhoHs8EQ1ql0R/TsUgaRPhpKNxIMrKQfWUp8QSE= -k8s.io/apiextensions-apiserver v0.30.0 h1:jcZFKMqnICJfRxTgnC4E+Hpcq8UEhT8B2lhBcQ+6uAs= -k8s.io/apiextensions-apiserver v0.30.0/go.mod h1:N9ogQFGcrbWqAY9p2mUAL5mGxsLqwgtUce127VtRX5Y= -k8s.io/apimachinery v0.30.0 h1:qxVPsyDM5XS96NIh9Oj6LavoVFYff/Pon9cZeDIkHHA= -k8s.io/apimachinery v0.30.0/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= -k8s.io/client-go v0.30.0 h1:sB1AGGlhY/o7KCyCEQ0bPWzYDL0pwOZO4vAtTSh/gJQ= -k8s.io/client-go v0.30.0/go.mod h1:g7li5O5256qe6TYdAMyX/otJqMhIiGgTapdLchhmOaY= +k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= +k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= +k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= +k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/kor b/kor new file mode 100755 index 00000000..5db9950d Binary files /dev/null and b/kor differ diff --git a/pkg/kor/all.go b/pkg/kor/all.go index 7f6ab15d..4dfea912 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -28,16 +28,22 @@ func getUnusedCMs(clientset kubernetes.Interface, namespace string, filterOpts * if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "configmaps", namespace, err) } - namespaceCMDiff := ResourceDiff{"ConfigMap", cmDiff} + namespaceCMDiff := ResourceDiff{ + "ConfigMap", + cmDiff, + } return namespaceCMDiff } func getUnusedSVCs(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - svcDiff, err := ProcessNamespaceServices(clientset, namespace, filterOpts) + svcDiff, err := processNamespaceServices(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "services", namespace, err) } - namespaceSVCDiff := ResourceDiff{"Service", svcDiff} + namespaceSVCDiff := ResourceDiff{ + "Service", + svcDiff, + } return namespaceSVCDiff } @@ -46,7 +52,10 @@ func getUnusedSecrets(clientset kubernetes.Interface, namespace string, filterOp if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "secrets", namespace, err) } - namespaceSecretDiff := ResourceDiff{"Secret", secretDiff} + namespaceSecretDiff := ResourceDiff{ + "Secret", + secretDiff, + } return namespaceSecretDiff } @@ -55,25 +64,34 @@ func getUnusedServiceAccounts(clientset kubernetes.Interface, namespace string, if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "serviceaccounts", namespace, err) } - namespaceSADiff := ResourceDiff{"ServiceAccount", saDiff} + namespaceSADiff := ResourceDiff{ + "ServiceAccount", + saDiff, + } return namespaceSADiff } func getUnusedDeployments(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - deployDiff, err := ProcessNamespaceDeployments(clientset, namespace, filterOpts) + deployDiff, err := processNamespaceDeployments(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "deployments", namespace, err) } - namespaceSADiff := ResourceDiff{"Deployment", deployDiff} + namespaceSADiff := ResourceDiff{ + "Deployment", + deployDiff, + } return namespaceSADiff } func getUnusedStatefulSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - stsDiff, err := ProcessNamespaceStatefulSets(clientset, namespace, filterOpts) + stsDiff, err := processNamespaceStatefulSets(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "statefulSets", namespace, err) } - namespaceSADiff := ResourceDiff{"StatefulSet", stsDiff} + namespaceSADiff := ResourceDiff{ + "StatefulSet", + stsDiff, + } return namespaceSADiff } @@ -82,7 +100,10 @@ func getUnusedRoles(clientset kubernetes.Interface, namespace string, filterOpts if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "roles", namespace, err) } - namespaceSADiff := ResourceDiff{"Role", roleDiff} + namespaceSADiff := ResourceDiff{ + "Role", + roleDiff, + } return namespaceSADiff } @@ -91,7 +112,10 @@ func getUnusedClusterRoles(clientset kubernetes.Interface, filterOpts *filters.O if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "clusterRoles", err) } - aDiff := ResourceDiff{"ClusterRole", clusterRoleDiff} + aDiff := ResourceDiff{ + "ClusterRole", + clusterRoleDiff, + } return aDiff } @@ -100,7 +124,10 @@ func getUnusedHpas(clientset kubernetes.Interface, namespace string, filterOpts if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "hpas", namespace, err) } - namespaceHpaDiff := ResourceDiff{"Hpa", hpaDiff} + namespaceHpaDiff := ResourceDiff{ + "Hpa", + hpaDiff, + } return namespaceHpaDiff } @@ -109,7 +136,10 @@ func getUnusedPvcs(clientset kubernetes.Interface, namespace string, filterOpts if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "pvcs", namespace, err) } - namespacePvcDiff := ResourceDiff{"Pvc", pvcDiff} + namespacePvcDiff := ResourceDiff{ + "Pvc", + pvcDiff, + } return namespacePvcDiff } @@ -118,7 +148,10 @@ func getUnusedIngresses(clientset kubernetes.Interface, namespace string, filter if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "ingresses", namespace, err) } - namespaceIngressDiff := ResourceDiff{"Ingress", ingressDiff} + namespaceIngressDiff := ResourceDiff{ + "Ingress", + ingressDiff, + } return namespaceIngressDiff } @@ -127,7 +160,10 @@ func getUnusedPdbs(clientset kubernetes.Interface, namespace string, filterOpts if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "pdbs", namespace, err) } - namespacePdbDiff := ResourceDiff{"Pdb", pdbDiff} + namespacePdbDiff := ResourceDiff{ + "Pdb", + pdbDiff, + } return namespacePdbDiff } @@ -136,7 +172,10 @@ func getUnusedCrds(apiExtClient apiextensionsclientset.Interface, dynamicClient if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "Crds", err) } - allCrdDiff := ResourceDiff{"Crd", crdDiff} + allCrdDiff := ResourceDiff{ + "Crd", + crdDiff, + } return allCrdDiff } @@ -145,43 +184,58 @@ func getUnusedPvs(clientset kubernetes.Interface, filterOpts *filters.Options) R if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "Pvs", err) } - allPvDiff := ResourceDiff{"Pv", pvDiff} + allPvDiff := ResourceDiff{ + "Pv", + pvDiff, + } return allPvDiff } func getUnusedPods(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - podDiff, err := ProcessNamespacePods(clientset, namespace, filterOpts) + podDiff, err := processNamespacePods(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "pods", namespace, err) } - namespacePodDiff := ResourceDiff{"Pod", podDiff} + namespacePodDiff := ResourceDiff{ + "Pod", + podDiff, + } return namespacePodDiff } func getUnusedJobs(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - jobDiff, err := ProcessNamespaceJobs(clientset, namespace, filterOpts) + jobDiff, err := processNamespaceJobs(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "jobs", namespace, err) } - namespaceJobDiff := ResourceDiff{"Job", jobDiff} + namespaceJobDiff := ResourceDiff{ + "Job", + jobDiff, + } return namespaceJobDiff } func getUnusedReplicaSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - replicaSetDiff, err := ProcessNamespaceReplicaSets(clientset, namespace, filterOpts) + replicaSetDiff, err := processNamespaceReplicaSets(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "jobs", namespace, err) } - namespaceRSDiff := ResourceDiff{"ReplicaSet", replicaSetDiff} + namespaceRSDiff := ResourceDiff{ + "ReplicaSet", + replicaSetDiff, + } return namespaceRSDiff } func getUnusedDaemonSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff { - dsDiff, err := ProcessNamespaceDaemonSets(clientset, namespace, filterOpts) + dsDiff, err := processNamespaceDaemonSets(clientset, namespace, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "DaemonSets", namespace, err) } - namespaceSADiff := ResourceDiff{"DaemonSet", dsDiff} + namespaceSADiff := ResourceDiff{ + "DaemonSet", + dsDiff, + } return namespaceSADiff } @@ -190,64 +244,63 @@ func getUnusedStorageClasses(clientset kubernetes.Interface, filterOpts *filters if err != nil { fmt.Fprintf(os.Stderr, "Failed to get %s: %v\n", "StorageClasses", err) } - allScDiff := ResourceDiff{"StorageClass", scDiff} + allScDiff := ResourceDiff{ + "StorageClass", + scDiff, + } return allScDiff } func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - var allDiffs []ResourceDiff - namespaceCMDiff := getUnusedCMs(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceCMDiff) - namespaceSVCDiff := getUnusedSVCs(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceSVCDiff) - namespaceSecretDiff := getUnusedSecrets(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceSecretDiff) - namespaceSADiff := getUnusedServiceAccounts(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceSADiff) - namespaceDeploymentDiff := getUnusedDeployments(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceDeploymentDiff) - namespaceStatefulsetDiff := getUnusedStatefulSets(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceStatefulsetDiff) - namespaceRoleDiff := getUnusedRoles(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceRoleDiff) - namespaceHpaDiff := getUnusedHpas(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceHpaDiff) - namespacePvcDiff := getUnusedPvcs(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespacePvcDiff) - namespacePodDiff := getUnusedPods(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespacePodDiff) - namespaceIngressDiff := getUnusedIngresses(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceIngressDiff) - namespacePdbDiff := getUnusedPdbs(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespacePdbDiff) - namespaceJobDiff := getUnusedJobs(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceJobDiff) - namespaceRSDiff := getUnusedReplicaSets(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceRSDiff) - namespaceDaemonsetDiff := getUnusedDaemonSets(clientset, namespace, filterOpts) - allDiffs = append(allDiffs, namespaceDaemonsetDiff) - - output := FormatOutputAll(namespace, allDiffs, opts) - - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - for _, diff := range allDiffs { - resourceMap[diff.resourceType] = diff.diff + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + switch opts.GroupBy { + case "namespace": + resources[namespace] = make(map[string][]string) + resources[namespace]["ConfigMap"] = getUnusedCMs(clientset, namespace, filterOpts).diff + resources[namespace]["Service"] = getUnusedSVCs(clientset, namespace, filterOpts).diff + resources[namespace]["Secret"] = getUnusedSecrets(clientset, namespace, filterOpts).diff + resources[namespace]["ServiceAccount"] = getUnusedServiceAccounts(clientset, namespace, filterOpts).diff + resources[namespace]["Deployment"] = getUnusedDeployments(clientset, namespace, filterOpts).diff + resources[namespace]["StatefulSet"] = getUnusedStatefulSets(clientset, namespace, filterOpts).diff + resources[namespace]["Role"] = getUnusedRoles(clientset, namespace, filterOpts).diff + resources[namespace]["Hpa"] = getUnusedHpas(clientset, namespace, filterOpts).diff + resources[namespace]["Pvc"] = getUnusedPvcs(clientset, namespace, filterOpts).diff + resources[namespace]["Pod"] = getUnusedPods(clientset, namespace, filterOpts).diff + resources[namespace]["Ingress"] = getUnusedIngresses(clientset, namespace, filterOpts).diff + resources[namespace]["Pdb"] = getUnusedPdbs(clientset, namespace, filterOpts).diff + resources[namespace]["Job"] = getUnusedJobs(clientset, namespace, filterOpts).diff + resources[namespace]["ReplicaSet"] = getUnusedReplicaSets(clientset, namespace, filterOpts).diff + resources[namespace]["DaemonSet"] = getUnusedDaemonSets(clientset, namespace, filterOpts).diff + case "resource": + appendResources(resources, "ConfigMap", namespace, getUnusedCMs(clientset, namespace, filterOpts).diff) + appendResources(resources, "Service", namespace, getUnusedSVCs(clientset, namespace, filterOpts).diff) + appendResources(resources, "Secret", namespace, getUnusedSecrets(clientset, namespace, filterOpts).diff) + appendResources(resources, "ServiceAccount", namespace, getUnusedServiceAccounts(clientset, namespace, filterOpts).diff) + appendResources(resources, "Deployment", namespace, getUnusedDeployments(clientset, namespace, filterOpts).diff) + appendResources(resources, "StatefulSet", namespace, getUnusedStatefulSets(clientset, namespace, filterOpts).diff) + appendResources(resources, "Role", namespace, getUnusedRoles(clientset, namespace, filterOpts).diff) + appendResources(resources, "Hpa", namespace, getUnusedHpas(clientset, namespace, filterOpts).diff) + appendResources(resources, "Pvc", namespace, getUnusedPvcs(clientset, namespace, filterOpts).diff) + appendResources(resources, "Pod", namespace, getUnusedPods(clientset, namespace, filterOpts).diff) + appendResources(resources, "Ingress", namespace, getUnusedIngresses(clientset, namespace, filterOpts).diff) + appendResources(resources, "Pdb", namespace, getUnusedPdbs(clientset, namespace, filterOpts).diff) + 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) } - response[namespace] = resourceMap } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedAllNamespaced, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) @@ -259,44 +312,31 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In } func GetUnusedAllNonNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) { + resources := make(map[string]map[string][]string) + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]string) + resources[""]["Crd"] = getUnusedCrds(apiExtClient, dynamicClient, filterOpts).diff + resources[""]["Pv"] = getUnusedPvs(clientset, filterOpts).diff + resources[""]["ClusterRole"] = getUnusedClusterRoles(clientset, filterOpts).diff + resources[""]["StorageClass"] = getUnusedStorageClasses(clientset, filterOpts).diff + case "resource": + appendResources(resources, "Crd", "", getUnusedCrds(apiExtClient, dynamicClient, filterOpts).diff) + appendResources(resources, "Pv", "", getUnusedPvs(clientset, filterOpts).diff) + appendResources(resources, "ClusterRole", "", getUnusedClusterRoles(clientset, filterOpts).diff) + appendResources(resources, "StorageClass", "", getUnusedStorageClasses(clientset, filterOpts).diff) + } + var outputBuffer bytes.Buffer - response := make(map[string]map[string][]string) - - var allDiffs []ResourceDiff - noNamespaceResourceMap := make(map[string][]string) - crdDiff := getUnusedCrds(apiExtClient, dynamicClient, filterOpts) - 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 - - clusterRoleDiff := getUnusedClusterRoles(clientset, filterOpts) - clusterRoleOutput := FormatOutputAll("", []ResourceDiff{clusterRoleDiff}, opts) - outputBuffer.WriteString(clusterRoleOutput) - outputBuffer.WriteString("\n") - noNamespaceResourceMap[clusterRoleDiff.resourceType] = clusterRoleDiff.diff - - storageClassDiff := getUnusedStorageClasses(clientset, filterOpts) - storageClassOutput := FormatOutputAll("", []ResourceDiff{storageClassDiff}, opts) - outputBuffer.WriteString(storageClassOutput) - outputBuffer.WriteString("\n") - noNamespaceResourceMap[storageClassDiff.resourceType] = storageClassDiff.diff - - output := FormatOutputAll("", allDiffs, opts) - - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - response[""] = noNamespaceResourceMap - - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", err + 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 + } } unusedAllNonNamespaced, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/clusterroles.go b/pkg/kor/clusterroles.go index 139bf810..557c2a29 100644 --- a/pkg/kor/clusterroles.go +++ b/pkg/kor/clusterroles.go @@ -172,38 +172,34 @@ func processClusterRoles(clientset kubernetes.Interface, filterOpts *filters.Opt } 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) - + resources := 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 + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]string) + resources[""]["ClusterRole"] = diff + case "resource": + appendResources(resources, "ClusterRole", "", 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 + 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 + } } unusedClusterRoles, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/clusterroles_test.go b/pkg/kor/clusterroles_test.go index 2b23a971..919f471d 100644 --- a/pkg/kor/clusterroles_test.go +++ b/pkg/kor/clusterroles_test.go @@ -95,7 +95,11 @@ func TestRetrieveUsedClusterRoles(t *testing.T) { t.Errorf("Expected 3 used cluster role, got %d", len(usedClusterRoles)) } - expectedRoles := []string{"test-clusterRole2", "test-clusterRole3", "test-clusterRole6"} + expectedRoles := []string{ + "test-clusterRole2", + "test-clusterRole3", + "test-clusterRole6", + } sort.Strings(usedClusterRoles) t.Log(usedClusterRoles) if !reflect.DeepEqual(usedClusterRoles, expectedRoles) { @@ -140,6 +144,7 @@ func TestGetUnusedClusterRolesStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedClusterRoles(&filters.Options{}, clientset, "json", opts) @@ -149,7 +154,10 @@ func TestGetUnusedClusterRolesStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ "": { - "ClusterRoles": {"test-clusterRole1", "test-clusterRole5"}, + "ClusterRole": { + "test-clusterRole1", + "test-clusterRole5", + }, }, } diff --git a/pkg/kor/configmaps.go b/pkg/kor/configmaps.go index 6a733cca..5b6acac7 100644 --- a/pkg/kor/configmaps.go +++ b/pkg/kor/configmaps.go @@ -150,35 +150,37 @@ func processNamespaceCM(clientset kubernetes.Interface, namespace string, filter } func GetUnusedConfigmaps(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - response := make(map[string]map[string][]string) - + resources := make(map[string]map[string][]string) for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceCM(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][]string) + resources[namespace]["ConfigMap"] = diff + case "resource": + appendResources(resources, "ConfigMap", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "ConfigMap", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete ConfigMap %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Configmaps", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["ConfigMap"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedCMs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/configmaps_test.go b/pkg/kor/configmaps_test.go index 845df2e3..065c4f01 100644 --- a/pkg/kor/configmaps_test.go +++ b/pkg/kor/configmaps_test.go @@ -58,14 +58,20 @@ func createTestConfigmaps(t *testing.T) *fake.Clientset { } pod1 := CreateTestPod(testNamespace, "pod-1", "", []corev1.Volume{ - {Name: "vol-1", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: configmap1.ObjectMeta.Name}}}}, + { + Name: "vol-1", + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: configmap1.ObjectMeta.Name}}}, + }, }, AppLabels) pod2 := CreateTestPod(testNamespace, "pod-2", "", nil, AppLabels) pod2.Spec.Containers = []corev1.Container{ { Env: []corev1.EnvVar{ - {Name: "ENV_VAR_1", ValueFrom: &corev1.EnvVarSource{ConfigMapKeyRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: configmap1.ObjectMeta.Name}}}}, + { + Name: "ENV_VAR_1", + ValueFrom: &corev1.EnvVarSource{ConfigMapKeyRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: configmap1.ObjectMeta.Name}}}, + }, }, }, } @@ -83,7 +89,10 @@ func createTestConfigmaps(t *testing.T) *fake.Clientset { pod4.Spec.InitContainers = []corev1.Container{ { Env: []corev1.EnvVar{ - {Name: "INIT_ENV_VAR_1", ValueFrom: &corev1.EnvVarSource{ConfigMapKeyRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: configmap2.ObjectMeta.Name}}}}, + { + Name: "INIT_ENV_VAR_1", + ValueFrom: &corev1.EnvVarSource{ConfigMapKeyRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: configmap2.ObjectMeta.Name}}}, + }, }, }, } @@ -120,7 +129,11 @@ func TestRetrieveConfigMapNames(t *testing.T) { t.Fatalf("Error retrieving configmap names: %v", err) } - expectedConfigMapNames := []string{"configmap-1", "configmap-2", "configmap-3"} + expectedConfigMapNames := []string{ + "configmap-1", + "configmap-2", + "configmap-3", + } if !equalSlices(configMapNames, expectedConfigMapNames) { t.Errorf("Expected configmap names %v, got %v", expectedConfigMapNames, configMapNames) } @@ -134,7 +147,10 @@ func TestProcessNamespaceCM(t *testing.T) { t.Fatalf("Error processing namespace CM: %v", err) } - unusedConfigmaps := []string{"configmap-3", "configmap-5"} + unusedConfigmaps := []string{ + "configmap-3", + "configmap-5", + } if !equalSlices(diff, unusedConfigmaps) { t.Errorf("Expected diff %v, got %v", unusedConfigmaps, diff) } @@ -149,7 +165,12 @@ func TestRetrieveUsedCM(t *testing.T) { t.Fatalf("Error retrieving used ConfigMaps: %v", err) } - expectedVolumesCM := []string{"configmap-1", "kube-root-ca.crt"} + expectedVolumesCM := []string{ + "configmap-1", + "kube-root-ca.crt", + "openshift-service-ca.crt", + } + if !equalSlices(volumesCM, expectedVolumesCM) { t.Errorf("Expected volume configmaps %v, got %v", expectedVolumesCM, volumesCM) } @@ -185,6 +206,7 @@ func TestGetUnusedConfigmapsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedConfigmaps(&filters.Options{}, clientset, "json", opts) @@ -194,7 +216,10 @@ func TestGetUnusedConfigmapsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "ConfigMap": {"configmap-3", "configmap-5"}, + "ConfigMap": { + "configmap-3", + "configmap-5", + }, }, } diff --git a/pkg/kor/crds.go b/pkg/kor/crds.go index 1383395d..86f13c01 100644 --- a/pkg/kor/crds.go +++ b/pkg/kor/crds.go @@ -59,32 +59,30 @@ func processCrds(apiExtClient apiextensionsclientset.Interface, dynamicClient dy return unusedCRDs, nil } -func GetUnusedCrds(filterOpts *filters.Options, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) { - - var outputBuffer bytes.Buffer +func GetUnusedCrds(_ *filters.Options, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts Opts) (string, error) { + resources := make(map[string]map[string][]string) diff, err := processCrds(apiExtClient, dynamicClient, &filters.Options{}) - - response := make(map[string]map[string][]string) - if err != nil { fmt.Fprintf(os.Stderr, "Failed to process crds: %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[""]["Crd"] = diff - } - output := FormatOutput("", diff, "Crds", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]string) + resources[""]["Crd"] = diff + case "resource": + appendResources(resources, "Crd", "", diff) } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedCRDs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/daemonsets.go b/pkg/kor/daemonsets.go index 7ef5e3b5..e597b1c8 100644 --- a/pkg/kor/daemonsets.go +++ b/pkg/kor/daemonsets.go @@ -17,7 +17,7 @@ import ( //go:embed exceptions/daemonsets/daemonsets.json var daemonsetsConfig []byte -func ProcessNamespaceDaemonSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespaceDaemonSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { daemonSetsList, err := clientset.AppsV1().DaemonSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -53,35 +53,37 @@ func ProcessNamespaceDaemonSets(clientset kubernetes.Interface, namespace string } func GetUnusedDaemonSets(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespaceDaemonSets(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceDaemonSets(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][]string) + resources[namespace]["DaemonSet"] = diff + case "resource": + appendResources(resources, "DaemonSet", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "DaemonSet", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete DaemonSet %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "DaemonSets", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["DaemonSets"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedDaemonSets, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/daemonsets_test.go b/pkg/kor/daemonsets_test.go index 30468d34..c09c4f4a 100644 --- a/pkg/kor/daemonsets_test.go +++ b/pkg/kor/daemonsets_test.go @@ -65,7 +65,7 @@ func createTestDaemonSets(t *testing.T) *fake.Clientset { func TestProcessNamespaceDaemonSets(t *testing.T) { clientset := createTestDaemonSets(t) - daemonSetsWithoutReplicas, err := ProcessNamespaceDaemonSets(clientset, testNamespace, &filters.Options{}) + daemonSetsWithoutReplicas, err := processNamespaceDaemonSets(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -88,6 +88,7 @@ func TestGetUnusedDaemonSetsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedDaemonSets(&filters.Options{}, clientset, "json", opts) @@ -97,7 +98,10 @@ func TestGetUnusedDaemonSetsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "DaemonSets": {"test-ds1", "test-ds4"}, + "DaemonSet": { + "test-ds1", + "test-ds4", + }, }, } diff --git a/pkg/kor/deployments.go b/pkg/kor/deployments.go index a2686171..81356197 100644 --- a/pkg/kor/deployments.go +++ b/pkg/kor/deployments.go @@ -13,7 +13,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -func ProcessNamespaceDeployments(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespaceDeployments(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { deploymentsList, err := clientset.AppsV1().Deployments(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -40,35 +40,37 @@ func ProcessNamespaceDeployments(clientset kubernetes.Interface, namespace strin } func GetUnusedDeployments(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - response := make(map[string]map[string][]string) - + resources := make(map[string]map[string][]string) for _, namespace := range filterOpts.Namespaces(clientset) { - diff, err := ProcessNamespaceDeployments(clientset, namespace, filterOpts) + diff, err := processNamespaceDeployments(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][]string) + resources[namespace]["Deployment"] = diff + case "resource": + appendResources(resources, "Deployment", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Deployment", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Deployment %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Deployments", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Deployments"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedDeployments, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/deployments_test.go b/pkg/kor/deployments_test.go index 5db9f7ae..9ea5b218 100644 --- a/pkg/kor/deployments_test.go +++ b/pkg/kor/deployments_test.go @@ -57,7 +57,7 @@ func createTestDeployments(t *testing.T) *fake.Clientset { func TestProcessNamespaceDeployments(t *testing.T) { clientset := createTestDeployments(t) - deploymentsWithoutReplicas, err := ProcessNamespaceDeployments(clientset, testNamespace, &filters.Options{}) + deploymentsWithoutReplicas, err := processNamespaceDeployments(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -80,6 +80,7 @@ func TestGetUnusedDeploymentsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedDeployments(&filters.Options{}, clientset, "json", opts) @@ -89,7 +90,10 @@ func TestGetUnusedDeploymentsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Deployments": {"test-deployment1", "test-deployment4"}, + "Deployment": { + "test-deployment1", + "test-deployment4", + }, }, } diff --git a/pkg/kor/exceptions/clusterroles/clusterroles.json b/pkg/kor/exceptions/clusterroles/clusterroles.json index e01578bc..bcf7fd59 100644 --- a/pkg/kor/exceptions/clusterroles/clusterroles.json +++ b/pkg/kor/exceptions/clusterroles/clusterroles.json @@ -83,6 +83,186 @@ { "Namespace": "", "ResourceName": "view" + }, + { + "Namespace": "", + "ResourceName": "system:node-bootstrapper" + }, + { + "Namespace": "", + "ResourceName": "system:aggregated-metrics-reader" + }, + { + "Namespace": "", + "ResourceName": "alert-routing-edit" + }, + { + "Namespace": "", + "ResourceName": "cluster-debugger" + }, + { + "Namespace": "", + "ResourceName": "global-operators-admin" + }, + { + "Namespace": "", + "ResourceName": "global-operators-edit" + }, + { + "Namespace": "", + "ResourceName": "global-operators-view" + }, + { + "Namespace": "", + "ResourceName": "monitoring-edit" + }, + { + "Namespace": "", + "ResourceName": "monitoring-rules-edit" + }, + { + "Namespace": "", + "ResourceName": "monitoring-rules-view" + }, + { + "Namespace": "", + "ResourceName": "olm-operators-admin" + }, + { + "Namespace": "", + "ResourceName": "olm-operators-edit" + }, + { + "Namespace": "", + "ResourceName": "olm-operators-view" + }, + { + "Namespace": "", + "ResourceName": "openshift-cluster-monitoring-admin" + }, + { + "Namespace": "", + "ResourceName": "openshift-cluster-monitoring-edit" + }, + { + "Namespace": "", + "ResourceName": "openshift-cluster-monitoring-view" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-main-attacher-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-main-provisioner-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-main-resizer-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-main-snapshotter-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-provisioner-configmap-and-secret-reader-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-provisioner-volumeattachment-reader-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-provisioner-volumesnapshot-reader-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-resizer-infrastructure-reader-role" + }, + { + "Namespace": "", + "ResourceName": "openshift-csi-resizer-storageclass-reader-role" + }, + { + "Namespace": "", + "ResourceName": "resource-metrics-server-resources" + }, + { + "Namespace": "", + "ResourceName": "storage-admin" + }, + { + "Namespace": "", + "ResourceName": "sudoer" + }, + { + "Namespace": "", + "ResourceName": "system:build-strategy-custom" + }, + { + "Namespace": "", + "ResourceName": "system:image-auditor" + }, + { + "Namespace": "", + "ResourceName": "system:image-pusher" + }, + { + "Namespace": "", + "ResourceName": "system:image-signer" + }, + { + "Namespace": "", + "ResourceName": "system:node-reader" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:aggregate-snapshots-to-storage-admin" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:aggregate-to-storage-admin" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:hostaccess" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:hostmount" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:hostnetwork" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:nonroot" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:nonroot-v2" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:privileged" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:scc:restricted" + }, + { + "Namespace": "", + "ResourceName": "system:openshift:templateservicebroker-client" + }, + { + "Namespace": "", + "ResourceName": "system:router" + }, + { + "Namespace": "", + "ResourceName": "system:sdn-manager" } ] } diff --git a/pkg/kor/exceptions/configmaps/configmaps.json b/pkg/kor/exceptions/configmaps/configmaps.json index 81d9cd5f..f75a95c3 100644 --- a/pkg/kor/exceptions/configmaps/configmaps.json +++ b/pkg/kor/exceptions/configmaps/configmaps.json @@ -4,6 +4,10 @@ "Namespace": "*", "ResourceName": "kube-root-ca.crt" }, + { + "Namespace": "*", + "ResourceName": "openshift-service-ca.crt" + }, { "Namespace": "gmp-system", "ResourceName": "config-images" @@ -76,6 +80,22 @@ "Namespace": "kube-system", "ResourceName": "overlay-upgrade-data" }, + { + "Namespace": "kube-system", + "ResourceName": "bootstrap" + }, + { + "Namespace": "kube-system", + "ResourceName": "cluster-config-v1" + }, + { + "Namespace": "kube-system", + "ResourceName": "root-ca" + }, + { + "Namespace": "kube-system", + "ResourceName": "cluster-dns" + }, { "Namespace": "kubernetes-dashboard", "ResourceName": "kubernetes-dashboard-settings" diff --git a/pkg/kor/exceptions/crds/crds.json b/pkg/kor/exceptions/crds/crds.json index 6bac7012..9f176ab5 100644 --- a/pkg/kor/exceptions/crds/crds.json +++ b/pkg/kor/exceptions/crds/crds.json @@ -83,6 +83,286 @@ { "Namespace": "", "ResourceName": "volumesnapshots.snapshot.storage.k8s.io" + }, + { + "Namespace": "", + "ResourceName": "etcdsnapshotfiles.k3s.cattle.io" + }, + { + "Namespace": "", + "ResourceName": "helmchartconfigs.helm.cattle.io" + }, + { + "Namespace": "", + "ResourceName": "ingressrouteudps.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "ingressrouteudps.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "middlewaretcps.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "ingressroutetcps.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "serverstransports.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "middlewares.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "serverstransports.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "tlsstores.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "traefikservices.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "tlsstores.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "tlsoptions.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "traefikservices.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "serverstransporttcps.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "middlewaretcps.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "ingressroutes.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "tlsoptions.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "ingressroutetcps.traefik.containo.us" + }, + { + "Namespace": "", + "ResourceName": "middlewares.traefik.io" + }, + { + "Namespace": "", + "ResourceName": "adminpolicybasedexternalroutes.k8s.ovn.org" + }, + { + "Namespace": "", + "ResourceName": "alertingrules.monitoring.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "alertmanagerconfigs.monitoring.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "alertrelabelconfigs.monitoring.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "baremetalhosts.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "bmceventsubscriptions.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "clusterautoscalers.autoscaling.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "clustercsidrivers.operator.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "clusterresourcequotas.quota.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "consoleexternalloglinks.console.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "consolelinks.console.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "consolenotifications.console.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "consolesamples.console.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "consoleyamlsamples.console.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "containerruntimeconfigs.machineconfiguration.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "controlplanemachinesets.machine.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "dnsrecords.ingress.operator.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "egressfirewalls.k8s.ovn.org" + }, + { + "Namespace": "", + "ResourceName": "egressips.k8s.ovn.org" + }, + { + "Namespace": "", + "ResourceName": "egressqoses.k8s.ovn.org" + }, + { + "Namespace": "", + "ResourceName": "egressrouters.network.operator.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "egressservices.k8s.ovn.org" + }, + { + "Namespace": "", + "ResourceName": "firmwareschemas.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "hardwaredata.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "hostfirmwaresettings.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "imagecontentpolicies.config.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "imagecontentsourcepolicies.operator.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "imagedigestmirrorsets.config.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "imagetagmirrorsets.config.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "installplans.operators.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "ippools.whereabouts.cni.cncf.io" + }, + { + "Namespace": "", + "ResourceName": "kubeletconfigs.machineconfiguration.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "machineautoscalers.autoscaling.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "machines.machine.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "machinesets.machine.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "metal3remediations.infrastructure.cluster.x-k8s.io" + }, + { + "Namespace": "", + "ResourceName": "metal3remediationtemplates.infrastructure.cluster.x-k8s.io" + }, + { + "Namespace": "", + "ResourceName": "network-attachment-definitions.k8s.cni.cncf.io" + }, + { + "Namespace": "", + "ResourceName": "operators.operators.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "overlappingrangeipreservations.whereabouts.cni.cncf.io" + }, + { + "Namespace": "", + "ResourceName": "performanceprofiles.performance.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "podmonitors.monitoring.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "preprovisioningimages.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "probes.monitoring.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "projecthelmchartrepositories.helm.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "provisionings.metal3.io" + }, + { + "Namespace": "", + "ResourceName": "rolebindingrestrictions.authorization.openshift.io" + }, + { + "Namespace": "", + "ResourceName": "storagestates.migration.k8s.io" + }, + { + "Namespace": "", + "ResourceName": "subscriptions.operators.coreos.com" + }, + { + "Namespace": "", + "ResourceName": "thanosrulers.monitoring.coreos.com" } ] } diff --git a/pkg/kor/exceptions/jobs/jobs.json b/pkg/kor/exceptions/jobs/jobs.json new file mode 100644 index 00000000..af4155e6 --- /dev/null +++ b/pkg/kor/exceptions/jobs/jobs.json @@ -0,0 +1,16 @@ +{ + "exceptionJobs": [ + { + "Namespace": "kube-system", + "ResourceName": "helm-install-traefik-crd" + }, + { + "Namespace": "kube-system", + "ResourceName": "helm-install-traefik" + }, + { + "Namespace": "assisted-installer", + "ResourceName": "assisted-installer-controller" + } + ] + } diff --git a/pkg/kor/exceptions/secrets/secrets.json b/pkg/kor/exceptions/secrets/secrets.json index a91be3fb..57232f28 100644 --- a/pkg/kor/exceptions/secrets/secrets.json +++ b/pkg/kor/exceptions/secrets/secrets.json @@ -4,6 +4,22 @@ "Namespace": "kube-system", "ResourceName": "bootstrap-token-*" }, + { + "Namespace": "kube-system", + "ResourceName": "k3s-serving" + }, + { + "Namespace": "kube-system", + "ResourceName": "*.node-password.k3s" + }, + { + "Namespace": "kube-system", + "ResourceName": "kube-cloud-cfg" + }, + { + "Namespace": "kube-system", + "ResourceName": "kubeadmin" + }, { "Namespace": "kubernetes-dashboard", "ResourceName": "kubernetes-dashboard-certs" @@ -15,6 +31,10 @@ { "Namespace": "kubernetes-dashboard", "ResourceName": "kubernetes-dashboard-key-holder" + }, + { + "Namespace": "assisted-installer", + "ResourceName": "assisted-installer-controller-dockercfg-*" } ] } diff --git a/pkg/kor/exceptions/services/services.json b/pkg/kor/exceptions/services/services.json index bb326184..ba48335d 100644 --- a/pkg/kor/exceptions/services/services.json +++ b/pkg/kor/exceptions/services/services.json @@ -7,6 +7,10 @@ { "Namespace": "kube-system", "ResourceName": "vpa-recommender" + }, + { + "Namespace": "kube-system", + "ResourceName": "docker.io-hostpath" } ] } diff --git a/pkg/kor/exceptions/storageclasses/storageclasses.json b/pkg/kor/exceptions/storageclasses/storageclasses.json index 6c1d97a8..7e2dd028 100644 --- a/pkg/kor/exceptions/storageclasses/storageclasses.json +++ b/pkg/kor/exceptions/storageclasses/storageclasses.json @@ -43,6 +43,10 @@ { "Namespace": "", "ResourceName": "standard-rwo" + }, + { + "Namespace": "", + "ResourceName": "local-path" } ] } diff --git a/pkg/kor/finalizers.go b/pkg/kor/finalizers.go index 09154897..a01a4bf7 100644 --- a/pkg/kor/finalizers.go +++ b/pkg/kor/finalizers.go @@ -97,9 +97,8 @@ func GetUnusedfinalizers(filterOpts *filters.Options, clientset kubernetes.Inter allDiffs[gvr.Resource] = resourceDiff } - output := FormatOutputFromMap(namespace, allDiffs, opts) + output := formatOutputForNamespace(namespace, allDiffs, opts) outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") response[namespace] = allDiffs } diff --git a/pkg/kor/hpas.go b/pkg/kor/hpas.go index 62de0166..b4a8047a 100644 --- a/pkg/kor/hpas.go +++ b/pkg/kor/hpas.go @@ -81,36 +81,37 @@ func processNamespaceHpas(clientset kubernetes.Interface, namespace string, filt } func GetUnusedHpas(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceHpas(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][]string) + resources[namespace]["Hpa"] = diff + case "resource": + appendResources(resources, "Hpa", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "HPA", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete HPA %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "HPAs", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Hpa"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedHpas, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/hpas_test.go b/pkg/kor/hpas_test.go index 961ce100..7f0fc950 100644 --- a/pkg/kor/hpas_test.go +++ b/pkg/kor/hpas_test.go @@ -89,6 +89,7 @@ func TestGetUnusedHpasStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedHpas(&filters.Options{}, clientset, "json", opts) @@ -98,7 +99,10 @@ func TestGetUnusedHpasStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Hpa": {"test-hpa2", "test-hpa4"}, + "Hpa": { + "test-hpa2", + "test-hpa4", + }, }, } diff --git a/pkg/kor/ingresses.go b/pkg/kor/ingresses.go index 431a22f9..f4ad87b0 100644 --- a/pkg/kor/ingresses.go +++ b/pkg/kor/ingresses.go @@ -107,36 +107,37 @@ func processNamespaceIngresses(clientset kubernetes.Interface, namespace string, } func GetUnusedIngresses(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceIngresses(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][]string) + resources[namespace]["Ingress"] = diff + case "resource": + appendResources(resources, "Ingress", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Ingress", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Ingress %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Ingresses", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Ingresses"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedIngresses, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/ingresses_test.go b/pkg/kor/ingresses_test.go index 751d2ef0..f1c1ce43 100644 --- a/pkg/kor/ingresses_test.go +++ b/pkg/kor/ingresses_test.go @@ -96,6 +96,7 @@ func TestGetUnusedIngressesStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedIngresses(&filters.Options{}, clientset, "json", opts) @@ -105,7 +106,10 @@ func TestGetUnusedIngressesStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Ingresses": {"test-ingress-2", "test-ingress-4"}, + "Ingress": { + "test-ingress-2", + "test-ingress-4", + }, }, } diff --git a/pkg/kor/jobs.go b/pkg/kor/jobs.go index 6dc131dc..3fb4cb4c 100644 --- a/pkg/kor/jobs.go +++ b/pkg/kor/jobs.go @@ -3,22 +3,32 @@ package kor import ( "bytes" "context" + _ "embed" "encoding/json" "fmt" "os" + batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "github.com/yonahd/kor/pkg/filters" ) -func ProcessNamespaceJobs(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +//go:embed exceptions/jobs/jobs.json +var jobsConfig []byte + +func processNamespaceJobs(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { jobsList, err := clientset.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err } + config, err := unmarshalConfig(jobsConfig) + if err != nil { + return nil, err + } + var unusedJobNames []string for _, job := range jobsList.Items { @@ -26,9 +36,22 @@ func ProcessNamespaceJobs(clientset kubernetes.Interface, namespace string, filt continue } + if isResourceException(job.Name, job.Namespace, config.ExceptionJobs) { + continue + } + // if the job has completionTime and succeeded count greater than zero, think the job is completed if job.Status.CompletionTime != nil && job.Status.Succeeded > 0 { unusedJobNames = append(unusedJobNames, job.Name) + continue + } else { + // Check if the job has a condition indicating it has exceeded the backoff limit + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Reason == "BackoffLimitExceeded" { + unusedJobNames = append(unusedJobNames, job.Name) + break + } + } } } @@ -36,36 +59,37 @@ func ProcessNamespaceJobs(clientset kubernetes.Interface, namespace string, filt } func GetUnusedJobs(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespaceJobs(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceJobs(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][]string) + resources[namespace]["Job"] = diff + case "resource": + appendResources(resources, "Job", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Job", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Job %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Job", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Jobs"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedJobs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/jobs_test.go b/pkg/kor/jobs_test.go index e0e750a9..6fe0235b 100644 --- a/pkg/kor/jobs_test.go +++ b/pkg/kor/jobs_test.go @@ -71,23 +71,44 @@ func createTestJobs(t *testing.T) *fake.Clientset { t.Fatalf("Error creating fake job: %v", err) } + job5 := CreateTestJob(testNamespace, "test-job5", &batchv1.JobStatus{ + Succeeded: 0, + Failed: 1, + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: corev1.ConditionTrue, + Reason: "BackoffLimitExceeded", + Message: "Job has reached the specified backoff limit", + }, + }, + }, AppLabels) + + _, err = clientset.BatchV1().Jobs(testNamespace).Create(context.TODO(), job5, v1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating fake job: %v", err) + } + return clientset } func TestProcessNamespaceJobs(t *testing.T) { clientset := createTestJobs(t) - completedJobs, err := ProcessNamespaceJobs(clientset, testNamespace, &filters.Options{}) + unusedJobs, err := processNamespaceJobs(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Expected no error, got %v", err) } - if len(completedJobs) != 2 { - t.Errorf("Expected 2 job been completed, got %d", len(completedJobs)) + if len(unusedJobs) != 3 { + t.Errorf("Expected 3 jobs unused got %d", len(unusedJobs)) } - if completedJobs[0] != "test-job2" && completedJobs[1] != "test-job4" { - t.Errorf("job2', got %s", completedJobs[0]) + expectedJobs := []string{"test-job2", "test-job4", "test-job5"} + for _, jobName := range expectedJobs { + if !contains(unusedJobs, jobName) { + t.Errorf("Expected job %s to be considered unused, but it was not found", jobName) + } } } @@ -100,6 +121,7 @@ func TestGetUnusedJobsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedJobs(&filters.Options{}, clientset, "json", opts) @@ -109,7 +131,11 @@ func TestGetUnusedJobsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Jobs": {"test-job2", "test-job4"}, + "Job": { + "test-job2", + "test-job4", + "test-job5", + }, }, } diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go index 6df7a425..650d9c83 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -38,6 +38,7 @@ type Config struct { ExceptionServiceAccounts []ExceptionResource `json:"exceptionServiceAccounts"` ExceptionServices []ExceptionResource `json:"exceptionServices"` ExceptionStorageClasses []ExceptionResource `json:"exceptionStorageClasses"` + ExceptionJobs []ExceptionResource `json:"exceptionJobs"` // Add other configurations if needed } @@ -48,6 +49,7 @@ type Opts struct { WebhookURL string Channel string Token string + GroupBy string } func RemoveDuplicatesAndSort(slice []string) []string { @@ -129,116 +131,125 @@ func GetDynamicClient(kubeconfig string) *dynamic.DynamicClient { return clientset } -func FormatOutput(namespace string, resources []string, resourceType string, opts Opts) string { - if opts.Verbose && len(resources) == 0 { - return fmt.Sprintf("No unused %s found in the namespace: %s \n", resourceType, namespace) - } else if len(resources) == 0 { - return "" +func appendResources(resources map[string]map[string][]string, resourceType, namespace string, diff []string) { + for _, d := range diff { + if _, ok := resources[resourceType]; !ok { + resources[resourceType] = make(map[string][]string) + } + resources[resourceType][namespace] = append(resources[resourceType][namespace], d) } +} - var buf bytes.Buffer - table := tablewriter.NewWriter(&buf) - table.SetHeader([]string{ - "#", - "Resource Name", - }) - - for i, name := range resources { - table.Append([]string{ - fmt.Sprintf("%d", i+1), - name, - }) +func getTableHeader(groupBy string) []string { + switch groupBy { + case "namespace": + return []string{ + "#", + "RESOURCE TYPE", + "RESOURCE NAME", + } + case "resource": + return []string{ + "#", + "NAMESPACE", + "RESOURCE NAME", + } + default: + return nil } +} - table.Render() +func getTableRow(index int, columns ...string) []string { + row := make([]string, 0, len(columns)+1) + row = append(row, fmt.Sprintf("%d", index+1)) + row = append(row, columns...) + return row +} - return fmt.Sprintf("Unused %s in Namespace: %s\n%s", resourceType, namespace, buf.String()) +// FormatOutput formats the output based on the group by option +func FormatOutput(resources map[string]map[string][]string, opts Opts) bytes.Buffer { + var output bytes.Buffer + switch opts.GroupBy { + case "namespace": + for namespace, diffs := range resources { + output.WriteString(formatOutputForNamespace(namespace, diffs, opts)) + } + case "resource": + for resource, diffs := range resources { + output.WriteString(formatOutputForResource(resource, diffs, opts)) + } + } + return output } -func FormatOutputFromMap(namespace string, allDiffs map[string][]string, opts Opts) string { - i := 0 +func formatOutputForResource(resource string, resources map[string][]string, opts Opts) string { + if len(resources) == 0 { + if opts.Verbose { + return fmt.Sprintf("No unused %ss found\n", resource) + } + return "" + } var buf bytes.Buffer table := tablewriter.NewWriter(&buf) - table.SetHeader([]string{ - "#", - "Resource Type", - "Resource Name", - }) - - // TODO parse resourceType, diff - - allEmpty := true - for resourceType, diff := range allDiffs { - if len(diff) == 0 { - continue + table.SetHeader(getTableHeader(opts.GroupBy)) + var index int + for ns, diffs := range resources { + for _, d := range diffs { + row := getTableRow(index, ns, d) + table.Append(row) + index++ } + } + table.Render() + return fmt.Sprintf("Unused %ss:\n%s\n", resource, buf.String()) +} - allEmpty = false +func formatOutputForNamespace(namespace string, resources map[string][]string, opts Opts) string { + var buf bytes.Buffer + table := tablewriter.NewWriter(&buf) + table.SetHeader(getTableHeader(opts.GroupBy)) + allEmpty := true + var index int + for resourceType, diff := range resources { for _, val := range diff { - row := []string{ - fmt.Sprintf("%d", i+1), - resourceType, - val, - } + row := getTableRow(index, resourceType, val) table.Append(row) - i += 1 + allEmpty = false + index++ } } - - if opts.Verbose && allEmpty { - return fmt.Sprintf("No unused resources found in the namespace: %s", namespace) - } else if allEmpty { + if allEmpty { + if opts.Verbose { + return fmt.Sprintf("No unused resources found in the namespace: %q\n", namespace) + } return "" } - table.Render() - if namespace == "" { - return fmt.Sprintf("Unused CRDs: \n%s", buf.String()) - } - return fmt.Sprintf("Unused Resources in Namespace: %s\n%s", namespace, buf.String()) + return fmt.Sprintf("Unused resources in namespace: %q\n%s\n", namespace, buf.String()) } func FormatOutputAll(namespace string, allDiffs []ResourceDiff, opts Opts) string { - i := 0 var buf bytes.Buffer table := tablewriter.NewWriter(&buf) - table.SetHeader([]string{ - "#", - "Resource Type", - "Resource Name", - }) - - // TODO parse resourceType, diff - + table.SetHeader(getTableHeader(opts.GroupBy)) allEmpty := true + var index int for _, data := range allDiffs { - if len(data.diff) == 0 { - continue - } - - allEmpty = false for _, val := range data.diff { - row := []string{ - fmt.Sprintf("%d", i+1), - data.resourceType, - val, - } + row := getTableRow(index, data.resourceType, val) table.Append(row) - i += 1 + allEmpty = false + index++ } } - - if opts.Verbose && allEmpty { - return fmt.Sprintf("No unused resources found in the namespace: %s", namespace) - } else if allEmpty { + if allEmpty { + if opts.Verbose { + return fmt.Sprintf("No unused resources found in the namespace: %q\n", namespace) + } return "" } - table.Render() - if namespace == "" { - 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()) + return fmt.Sprintf("Unused resources in namespace: %q\n%s\n", namespace, buf.String()) } // TODO create formatter by resource "#", "Resource Name", "Namespace" @@ -261,24 +272,20 @@ func CalculateResourceDifference(usedResourceNames []string, allResourceNames [] } func unusedResourceFormatter(outputFormat string, outputBuffer bytes.Buffer, opts Opts, jsonResponse []byte) (string, error) { - if outputFormat == "table" { - - if opts.WebhookURL != "" || opts.Channel != "" && opts.Token != "" { - if err := SendToSlack(SlackMessage{}, opts, outputBuffer.String()); err != nil { - fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) - os.Exit(1) - } - } else { + switch outputFormat { + case "table": + if opts.WebhookURL == "" || opts.Channel == "" || opts.Token != "" { return outputBuffer.String(), nil } - } else { - if outputFormat == "yaml" { - yamlResponse, err := yaml.JSONToYAML(jsonResponse) - if err != nil { - fmt.Printf("err: %v\n", err) - } - return string(yamlResponse), nil + if err := SendToSlack(SlackMessage{}, opts, outputBuffer.String()); err != nil { + return "", fmt.Errorf("failed to send message to slack: %w", err) + } + case "yaml": + yamlResponse, err := yaml.JSONToYAML(jsonResponse) + if err != nil { + return "", fmt.Errorf("failed to convert json to yaml: %w", err) } + return string(yamlResponse), nil } return string(jsonResponse), nil } diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go index 17ced677..09086537 100644 --- a/pkg/kor/multi.go +++ b/pkg/kor/multi.go @@ -109,7 +109,6 @@ func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset 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 @@ -139,7 +138,6 @@ func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset output := FormatOutputAll(namespace, allDiffs, opts) if output != "" { outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") resourceMap := make(map[string][]string) for _, diff := range allDiffs { @@ -147,7 +145,6 @@ func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset } response[namespace] = resourceMap } - } jsonResponse, err := json.MarshalIndent(response, "", " ") diff --git a/pkg/kor/pdbs.go b/pkg/kor/pdbs.go index ce9e1828..690e5567 100644 --- a/pkg/kor/pdbs.go +++ b/pkg/kor/pdbs.go @@ -56,36 +56,37 @@ func processNamespacePdbs(clientset kubernetes.Interface, namespace string, filt } func GetUnusedPdbs(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespacePdbs(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][]string) + resources[namespace]["Pdb"] = diff + case "resource": + appendResources(resources, "Pdb", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "PDB", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete PDB %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "PDBs", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Pdb"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedPdbs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/pdbs_test.go b/pkg/kor/pdbs_test.go index a3d816c1..b357e5ce 100644 --- a/pkg/kor/pdbs_test.go +++ b/pkg/kor/pdbs_test.go @@ -99,6 +99,7 @@ func TestGetUnusedPdbsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedPdbs(&filters.Options{}, clientset, "json", opts) @@ -108,7 +109,10 @@ func TestGetUnusedPdbsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Pdb": {"test-pdb3", "test-pdb5"}, + "Pdb": { + "test-pdb3", + "test-pdb5", + }, }, } diff --git a/pkg/kor/pods.go b/pkg/kor/pods.go index 9aa1b1b9..46079448 100644 --- a/pkg/kor/pods.go +++ b/pkg/kor/pods.go @@ -14,7 +14,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -func ProcessNamespacePods(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespacePods(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { podsList, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -42,36 +42,37 @@ func ProcessNamespacePods(clientset kubernetes.Interface, namespace string, filt } func GetUnusedPods(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespacePods(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespacePods(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][]string) + resources[namespace]["Pod"] = diff + case "resource": + appendResources(resources, "Pod", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Pod", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Pod %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Pods", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Pods"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedPods, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/pods_test.go b/pkg/kor/pods_test.go index ab63a6e0..15167c8a 100644 --- a/pkg/kor/pods_test.go +++ b/pkg/kor/pods_test.go @@ -69,7 +69,14 @@ func createTestPods(t *testing.T) *fake.Clientset { Message: "", } - pods := []*corev1.Pod{pod1, pod2, pod3, pod4, pod5, pod6} + pods := []*corev1.Pod{ + pod1, + pod2, + pod3, + pod4, + pod5, + pod6, + } // Add test pods to the clientset for _, pod := range pods { @@ -84,12 +91,15 @@ func createTestPods(t *testing.T) *fake.Clientset { func TestProcessNamespacePods(t *testing.T) { clientset := createTestPods(t) - evictedPods, err := ProcessNamespacePods(clientset, testNamespace, &filters.Options{}) + evictedPods, err := processNamespacePods(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Unexpected error: %v", err) } - expectedEvictedPods := []string{"pod-2", "pod-6"} + expectedEvictedPods := []string{ + "pod-2", + "pod-6", + } if len(evictedPods) != len(expectedEvictedPods) { t.Errorf("Expected %d evicted pods, got %d", len(expectedEvictedPods), len(evictedPods)) @@ -111,6 +121,7 @@ func TestGetUnusedPodsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedPods(&filters.Options{}, clientset, "json", opts) @@ -120,7 +131,10 @@ func TestGetUnusedPodsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Pods": {"pod-2", "pod-6"}, + "Pod": { + "pod-2", + "pod-6", + }, }, } diff --git a/pkg/kor/pv.go b/pkg/kor/pv.go index 3aad62cf..7789378d 100644 --- a/pkg/kor/pv.go +++ b/pkg/kor/pv.go @@ -43,40 +43,34 @@ func processPvs(clientset kubernetes.Interface, filterOpts *filters.Options) ([] } func GetUnusedPvs(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - response := make(map[string]map[string][]string) - + resources := 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 + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]string) + resources[""]["Pv"] = diff + case "resource": + appendResources(resources, "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 + 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 + } } unusedPvs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/pv_test.go b/pkg/kor/pv_test.go index 70e16ade..3f8e5503 100644 --- a/pkg/kor/pv_test.go +++ b/pkg/kor/pv_test.go @@ -67,6 +67,7 @@ func TestGetUnusedPvs(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedPvs(&filters.Options{}, clientset, "json", opts) @@ -76,7 +77,10 @@ func TestGetUnusedPvs(t *testing.T) { expectedOutput := map[string]map[string][]string{ "": { - "Pv": {"test-pv2", "test-pv4"}, + "Pv": { + "test-pv2", + "test-pv4", + }, }, } diff --git a/pkg/kor/pvc.go b/pkg/kor/pvc.go index 2e19496e..ecd1c91d 100644 --- a/pkg/kor/pvc.go +++ b/pkg/kor/pvc.go @@ -64,36 +64,37 @@ func processNamespacePvcs(clientset kubernetes.Interface, namespace string, filt } func GetUnusedPvcs(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespacePvcs(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][]string) + resources[namespace]["Pvc"] = diff + case "resource": + appendResources(resources, "Pvc", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "PVC", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete PVC %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "PVCs", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Pvc"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedPvcs, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/pvc_test.go b/pkg/kor/pvc_test.go index 5f96b8e7..e838c271 100644 --- a/pkg/kor/pvc_test.go +++ b/pkg/kor/pvc_test.go @@ -105,6 +105,7 @@ func TestGetUnusedPvcsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedPvcs(&filters.Options{}, clientset, "json", opts) @@ -114,7 +115,10 @@ func TestGetUnusedPvcsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Pvc": {"test-pvc2", "test-pvc4"}, + "Pvc": { + "test-pvc2", + "test-pvc4", + }, }, } diff --git a/pkg/kor/replicaset.go b/pkg/kor/replicaset.go index 63798d49..c707e4da 100644 --- a/pkg/kor/replicaset.go +++ b/pkg/kor/replicaset.go @@ -13,7 +13,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -func ProcessNamespaceReplicaSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespaceReplicaSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { replicaSetList, err := clientset.AppsV1().ReplicaSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -36,36 +36,37 @@ func ProcessNamespaceReplicaSets(clientset kubernetes.Interface, namespace strin } func GetUnusedReplicaSets(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespaceReplicaSets(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceReplicaSets(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][]string) + resources[namespace]["ReplicaSet"] = diff + case "resource": + appendResources(resources, "ReplicaSet", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "ReplicaSet", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete ReplicaSet %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "ReplicaSets", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["ReplicaSets"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedReplicaSets, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/replicaset_test.go b/pkg/kor/replicaset_test.go index 57e6a2fa..1f7c2910 100644 --- a/pkg/kor/replicaset_test.go +++ b/pkg/kor/replicaset_test.go @@ -63,6 +63,7 @@ func TestProcessNamespaceReplicaSets(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedReplicaSets(&filters.Options{}, clientset, "json", opts) @@ -72,7 +73,7 @@ func TestProcessNamespaceReplicaSets(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "ReplicaSets": {"test-replicaSet2"}, + "ReplicaSet": {"test-replicaSet2"}, }, } diff --git a/pkg/kor/roles.go b/pkg/kor/roles.go index 38d14773..4c347e46 100644 --- a/pkg/kor/roles.go +++ b/pkg/kor/roles.go @@ -92,36 +92,37 @@ func processNamespaceRoles(clientset kubernetes.Interface, namespace string, fil } func GetUnusedRoles(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceRoles(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][]string) + resources[namespace]["Role"] = diff + case "resource": + appendResources(resources, "Role", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Role", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Role %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Roles", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Roles"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedRoles, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/roles_test.go b/pkg/kor/roles_test.go index 1ff5a4c0..c5681084 100644 --- a/pkg/kor/roles_test.go +++ b/pkg/kor/roles_test.go @@ -115,6 +115,7 @@ func TestGetUnusedRolesStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedRoles(&filters.Options{}, clientset, "json", opts) @@ -124,7 +125,10 @@ func TestGetUnusedRolesStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Roles": {"test-role2", "test-role4"}, + "Role": { + "test-role2", + "test-role4", + }, }, } diff --git a/pkg/kor/secrets.go b/pkg/kor/secrets.go index 75a98633..7c824ecf 100644 --- a/pkg/kor/secrets.go +++ b/pkg/kor/secrets.go @@ -19,6 +19,7 @@ import ( var exceptionSecretTypes = []string{ `helm.sh/release.v1`, `kubernetes.io/dockerconfigjson`, + `kubernetes.io/dockercfg`, `kubernetes.io/service-account-token`, } @@ -175,36 +176,37 @@ func processNamespaceSecret(clientset kubernetes.Interface, namespace string, fi } func GetUnusedSecrets(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceSecret(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][]string) + resources[namespace]["Secret"] = diff + case "resource": + appendResources(resources, "Secret", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Secret", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Secret %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Secrets", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Secrets"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedSecrets, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/secrets_test.go b/pkg/kor/secrets_test.go index 5c5b5bf2..d8b64f42 100644 --- a/pkg/kor/secrets_test.go +++ b/pkg/kor/secrets_test.go @@ -34,18 +34,27 @@ func createTestSecrets(t *testing.T) *fake.Clientset { secret5 := CreateTestSecret(testNamespace, "test-secret5", UnusedLabels) pod1 := CreateTestPod(testNamespace, "pod-1", "", []corev1.Volume{ - {Name: "vol-1", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "test-secret1"}}}, + { + Name: "vol-1", + VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "test-secret1"}}, + }, }, AppLabels) pod2 := CreateTestPod(testNamespace, "pod-2", "", []corev1.Volume{ - {Name: "vol-2", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "test-secret2"}}}, + { + Name: "vol-2", + VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "test-secret2"}}, + }, }, AppLabels) pod3 := CreateTestPod(testNamespace, "pod-3", "", nil, AppLabels) pod3.Spec.Containers = []corev1.Container{ { Env: []corev1.EnvVar{ - {Name: "ENV_VAR_1", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret1.ObjectMeta.Name}}}}, + { + Name: "ENV_VAR_1", + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret1.ObjectMeta.Name}}}, + }, }, }, } @@ -63,7 +72,10 @@ func createTestSecrets(t *testing.T) *fake.Clientset { pod5.Spec.InitContainers = []corev1.Container{ { Env: []corev1.EnvVar{ - {Name: "INIT_ENV_VAR_1", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret1.ObjectMeta.Name}}}}, + { + Name: "INIT_ENV_VAR_1", + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret1.ObjectMeta.Name}}}, + }, }, }, } @@ -178,7 +190,10 @@ func TestRetrieveUsedSecret(t *testing.T) { t.Fatalf("Error retrieving used secrets: %v", err) } - expectedVolumeSecrets := []string{"test-secret1", "test-secret2"} + expectedVolumeSecrets := []string{ + "test-secret1", + "test-secret2", + } if !equalSlices(volumeSecrets, expectedVolumeSecrets) { t.Errorf("Expected volume secrets %v, got %v", expectedVolumeSecrets, volumeSecrets) } @@ -198,7 +213,10 @@ func TestRetrieveUsedSecret(t *testing.T) { t.Errorf("Expected initContainer env secrets %v, got %v", expectedInitContainerEnvSecrets, initContainerEnvSecrets) } - expectedPullSecrets := []string{"test-secret1", "test-secret2"} + expectedPullSecrets := []string{ + "test-secret1", + "test-secret2", + } if !equalSlices(pullSecrets, expectedPullSecrets) { t.Errorf("Expected pull secrets %v, got %v", expectedPullSecrets, pullSecrets) } @@ -228,7 +246,10 @@ func TestRetrieveSecretNames(t *testing.T) { t.Fatalf("Error retrieving secret names: %v", err) } - expectedSecretNames := []string{"secret-1", "secret-2"} + expectedSecretNames := []string{ + "secret-1", + "secret-2", + } if !equalSlices(secretNames, expectedSecretNames) { t.Errorf("Expected secret names %v, got %v", expectedSecretNames, secretNames) } @@ -261,6 +282,7 @@ func TestGetUnusedSecretsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedSecrets(&filters.Options{}, clientset, "json", opts) @@ -270,7 +292,10 @@ func TestGetUnusedSecretsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Secrets": {"test-secret3", "test-secret5"}, + "Secret": { + "test-secret3", + "test-secret5", + }, }, } diff --git a/pkg/kor/serviceaccounts.go b/pkg/kor/serviceaccounts.go index 99222a56..7ca37ce4 100644 --- a/pkg/kor/serviceaccounts.go +++ b/pkg/kor/serviceaccounts.go @@ -155,37 +155,37 @@ func processNamespaceSA(clientset kubernetes.Interface, namespace string, filter } func GetUnusedServiceAccounts(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { diff, err := processNamespaceSA(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][]string) + resources[namespace]["ServiceAccount"] = diff + case "resource": + appendResources(resources, "ServiceAccount", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "ServiceAccount", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Serviceaccount %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Serviceaccounts", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["ServiceAccounts"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedServiceAccounts, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/serviceaccounts_test.go b/pkg/kor/serviceaccounts_test.go index eda4616f..83ad1a52 100644 --- a/pkg/kor/serviceaccounts_test.go +++ b/pkg/kor/serviceaccounts_test.go @@ -190,6 +190,7 @@ func TestGetUnusedServiceAccountsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedServiceAccounts(&filters.Options{}, clientset, "json", opts) @@ -199,7 +200,10 @@ func TestGetUnusedServiceAccountsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "ServiceAccounts": {"test-sa2", "test-sa4"}, + "ServiceAccount": { + "test-sa2", + "test-sa4", + }, }, } diff --git a/pkg/kor/services.go b/pkg/kor/services.go index 92f8acbd..e0735248 100644 --- a/pkg/kor/services.go +++ b/pkg/kor/services.go @@ -17,7 +17,7 @@ import ( //go:embed exceptions/services/services.json var servicesConfig []byte -func ProcessNamespaceServices(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespaceServices(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { endpointsList, err := clientset.CoreV1().Endpoints(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -52,37 +52,37 @@ func ProcessNamespaceServices(clientset kubernetes.Interface, namespace string, } func GetUnusedServices(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespaceServices(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceServices(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][]string) + resources[namespace]["Service"] = diff + case "resource": + appendResources(resources, "Service", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "Service", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Service %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Services", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Services"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedServices, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/services_test.go b/pkg/kor/services_test.go index 892d9c59..cd159d68 100644 --- a/pkg/kor/services_test.go +++ b/pkg/kor/services_test.go @@ -57,7 +57,7 @@ func createTestServices(t *testing.T) *fake.Clientset { func TestGetEndpointsWithoutSubsets(t *testing.T) { clientset := createTestServices(t) - servicesWithoutEndpoints, err := ProcessNamespaceServices(clientset, testNamespace, &filters.Options{}) + servicesWithoutEndpoints, err := processNamespaceServices(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -80,6 +80,7 @@ func TestGetUnusedServicesStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedServices(&filters.Options{}, clientset, "json", opts) @@ -89,7 +90,10 @@ func TestGetUnusedServicesStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Services": {"test-endpoint1", "test-endpoint4"}, + "Service": { + "test-endpoint1", + "test-endpoint4", + }, }, } diff --git a/pkg/kor/statefulsets.go b/pkg/kor/statefulsets.go index b7f85589..bd04eb4b 100644 --- a/pkg/kor/statefulsets.go +++ b/pkg/kor/statefulsets.go @@ -13,7 +13,7 @@ import ( "github.com/yonahd/kor/pkg/filters" ) -func ProcessNamespaceStatefulSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { +func processNamespaceStatefulSets(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]string, error) { statefulSetsList, err := clientset.AppsV1().StatefulSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) if err != nil { return nil, err @@ -40,35 +40,37 @@ func ProcessNamespaceStatefulSets(clientset kubernetes.Interface, namespace stri } func GetUnusedStatefulSets(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - namespaces := filterOpts.Namespaces(clientset) - response := make(map[string]map[string][]string) - - for _, namespace := range namespaces { - diff, err := ProcessNamespaceStatefulSets(clientset, namespace, filterOpts) + resources := make(map[string]map[string][]string) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceStatefulSets(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][]string) + resources[namespace]["StatefulSet"] = diff + case "resource": + appendResources(resources, "StatefulSet", namespace, diff) + } if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, namespace, "StatefulSet", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete Statefulset %s in namespace %s: %v\n", diff, namespace, err) } } - output := FormatOutput(namespace, diff, "Statefulsets", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - resourceMap := make(map[string][]string) - resourceMap["Statefulsets"] = diff - response[namespace] = resourceMap - } } - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedStatefulsets, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/statefulsets_test.go b/pkg/kor/statefulsets_test.go index 8fad0712..513f7307 100644 --- a/pkg/kor/statefulsets_test.go +++ b/pkg/kor/statefulsets_test.go @@ -57,7 +57,7 @@ func createTestStatefulSets(t *testing.T) *fake.Clientset { func TestProcessNamespaceStatefulSets(t *testing.T) { clientset := createTestStatefulSets(t) - statefulSetsWithoutReplicas, err := ProcessNamespaceStatefulSets(clientset, testNamespace, &filters.Options{}) + statefulSetsWithoutReplicas, err := processNamespaceStatefulSets(clientset, testNamespace, &filters.Options{}) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -80,6 +80,7 @@ func TestGetUnusedStatefulSetsStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedStatefulSets(&filters.Options{}, clientset, "json", opts) @@ -89,7 +90,10 @@ func TestGetUnusedStatefulSetsStructured(t *testing.T) { expectedOutput := map[string]map[string][]string{ testNamespace: { - "Statefulsets": {"test-sts1", "test-sts4"}, + "StatefulSet": { + "test-sts1", + "test-sts4", + }, }, } diff --git a/pkg/kor/storageclasses.go b/pkg/kor/storageclasses.go index 16858efc..df728e7d 100644 --- a/pkg/kor/storageclasses.go +++ b/pkg/kor/storageclasses.go @@ -93,40 +93,34 @@ func processStorageClasses(clientset kubernetes.Interface, filterOpts *filters.O } func GetUnusedStorageClasses(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts Opts) (string, error) { - var outputBuffer bytes.Buffer - response := make(map[string]map[string][]string) - + resources := make(map[string]map[string][]string) diff, err := processStorageClasses(clientset, filterOpts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to process storageClasses: %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[""]["StorageClass"] = diff + switch opts.GroupBy { + case "namespace": + resources[""] = make(map[string][]string) + resources[""]["StorageClass"] = diff + case "resource": + appendResources(resources, "StorageClass", "", diff) } - if opts.DeleteFlag { if diff, err = DeleteResource(diff, clientset, "", "StorageClass", opts.NoInteractive); err != nil { fmt.Fprintf(os.Stderr, "Failed to delete StorageClass %s: %v\n", diff, err) } } - output := FormatOutput("", diff, "StorageClasses", opts) - if output != "" { - outputBuffer.WriteString(output) - outputBuffer.WriteString("\n") - - response[""]["StorageClass"] = diff - - } - - jsonResponse, err := json.MarshalIndent(response, "", " ") - if err != nil { - return "", 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 + } } unusedStorageClasses, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) diff --git a/pkg/kor/storageclasses_test.go b/pkg/kor/storageclasses_test.go index 0c198ea2..ab856dd4 100644 --- a/pkg/kor/storageclasses_test.go +++ b/pkg/kor/storageclasses_test.go @@ -75,6 +75,7 @@ func TestGetUnusedStorageClassesStructured(t *testing.T) { Token: "", DeleteFlag: false, NoInteractive: true, + GroupBy: "namespace", } output, err := GetUnusedStorageClasses(&filters.Options{}, clientset, "json", opts)