diff --git a/README.md b/README.md index 295648a0..c308f4d4 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,14 @@ Kor provides various subcommands to identify and list unused resources. The avai ### Supported Flags ``` --e, --exclude-namespaces string Namespaces to be excluded, splited by comma. Example: --exclude-namespace ns1,ns2,ns3. If --include-namespace is set, --exclude-namespaces will be ignored. +-e, --exclude-namespaces string Namespaces to be excluded, split by comma. Example: --exclude-namespace ns1,ns2,ns3. If --include-namespace is set, --exclude-namespaces will be ignored. -h, --help help for kor --n, --include-namespaces string Namespaces to run on, split by comma. Example: --include-namespace ns1,ns2,ns3. +-n, --include-namespaces string Namespaces to run on, split by comma. Example: --include-namespace ns1,ns2,ns3. -k, --kubeconfig string Path to kubeconfig file (optional) - --output string Output format ("table" or "json") (default "table") + --output string Output format (table or json) (default "table") + --slack-auth-token string Slack auth token to send notifications to. --slack-auth-token requires --slack-channel to be set. + --slack-channel string Slack channel to send notifications to. --slack-channel requires --slack-auth-token to be set. + --slack-webhook-url string Slack webhook URL to send notifications to ``` To use a specific subcommand, run `kor [subcommand] [flags]`. @@ -135,6 +138,7 @@ helm upgrade -i kor \ --set cronJob.slackToken= \ ./charts/kor ``` +> Note: To send it to Slack as a file it's required to set the `slackToken` and `slackChannel` values. It's set to run every Monday at 1 a.m. by default. You can change the schedule by setting the `cronJob.schedule` value. diff --git a/charts/kor/templates/cronjob.yaml b/charts/kor/templates/cronjob.yaml index 0a0100ef..4f0454b2 100644 --- a/charts/kor/templates/cronjob.yaml +++ b/charts/kor/templates/cronjob.yaml @@ -19,9 +19,21 @@ spec: image: {{ .Values.cronJob.image.repository }}:{{ .Values.cronJob.image.tag }} command: ["/bin/sh", "-c"] {{- if .Values.cronJob.slackWebhookUrl }} - args: ["{{ .Values.cronJob.command }} --slack-webhook-url {{ .Values.cronJob.slackWebhookUrl }}"] + args: ["{{ .Values.cronJob.command }} --slack-webhook-url $SLACK_WEBHOOK_URL"] + env: + - name: SLACK_WEBHOOK_URL + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-slack-webhook-url-secret + key: slack-webhook-url {{- else if and .Values.cronJob.slackChannel .Values.cronJob.slackAuthToken }} - args: ["{{ .Values.cronJob.command }} --slack-channel {{ .Values.cronJob.slackChannel }} --slack-auth-token {{ .Values.cronJob.slackAuthToken }}"] + args: ["{{ .Values.cronJob.command }} --slack-channel {{ .Values.cronJob.slackChannel }} --slack-auth-token $SLACK_AUTH_TOKEN"] + env: + - name: SLACK_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-slack-auth-token-secret + key: slack-auth-token {{- else }} args: ["{{ .Values.cronJob.command }}"] {{- end }} diff --git a/charts/kor/templates/secret.yaml b/charts/kor/templates/secret.yaml new file mode 100644 index 00000000..9e27bdd9 --- /dev/null +++ b/charts/kor/templates/secret.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-slack-auth-token-secret +type: Opaque +data: + slack-auth-token: {{ .Values.cronJob.slackAuthToken | b64enc | quote }} + +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-slack-webhook-url-secret +type: Opaque +data: + slack-webhook-url: {{ .Values.cronJob.slackWebhookUrl | b64enc | quote }} diff --git a/cmd/kor/all.go b/cmd/kor/all.go index 5b3490fb..3aaf7e51 100644 --- a/cmd/kor/all.go +++ b/cmd/kor/all.go @@ -20,7 +20,7 @@ var allCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedAll(includeExcludeLists, clientset) + kor.GetUnusedAll(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/configmaps.go b/cmd/kor/configmaps.go index 49ed5cef..4d52e884 100644 --- a/cmd/kor/configmaps.go +++ b/cmd/kor/configmaps.go @@ -21,7 +21,7 @@ var configmapCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedConfigmaps(includeExcludeLists, clientset) + kor.GetUnusedConfigmaps(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/deployments.go b/cmd/kor/deployments.go index 61074f92..e56c01b7 100644 --- a/cmd/kor/deployments.go +++ b/cmd/kor/deployments.go @@ -21,7 +21,7 @@ var deployCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedDeployments(includeExcludeLists, clientset) + kor.GetUnusedDeployments(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/hpas.go b/cmd/kor/hpas.go index 1af3a3a5..35f074e3 100644 --- a/cmd/kor/hpas.go +++ b/cmd/kor/hpas.go @@ -21,7 +21,7 @@ var hpaCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedHpas(includeExcludeLists, clientset) + kor.GetUnusedHpas(includeExcludeLists, clientset, slackOpts) } }, } diff --git a/cmd/kor/ingresses.go b/cmd/kor/ingresses.go index 97f0c619..f48c2cce 100644 --- a/cmd/kor/ingresses.go +++ b/cmd/kor/ingresses.go @@ -21,7 +21,7 @@ var ingressCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedIngresses(includeExcludeLists, clientset) + kor.GetUnusedIngresses(includeExcludeLists, clientset, slackOpts) } }, } diff --git a/cmd/kor/pdbs.go b/cmd/kor/pdbs.go index 0cf774f6..2b060495 100644 --- a/cmd/kor/pdbs.go +++ b/cmd/kor/pdbs.go @@ -21,7 +21,7 @@ var pdbCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedPdbs(includeExcludeLists, clientset) + kor.GetUnusedPdbs(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/pvc.go b/cmd/kor/pvc.go index c0a02e0d..2b4cfba1 100644 --- a/cmd/kor/pvc.go +++ b/cmd/kor/pvc.go @@ -21,7 +21,7 @@ var pvcCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedPvcs(includeExcludeLists, clientset) + kor.GetUnusedPvcs(includeExcludeLists, clientset, slackOpts) } }, } diff --git a/cmd/kor/roles.go b/cmd/kor/roles.go index 44e9e227..65087fe5 100644 --- a/cmd/kor/roles.go +++ b/cmd/kor/roles.go @@ -21,7 +21,7 @@ var roleCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedRoles(includeExcludeLists, clientset) + kor.GetUnusedRoles(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/root.go b/cmd/kor/root.go index 6c5c1621..418a4152 100644 --- a/cmd/kor/root.go +++ b/cmd/kor/root.go @@ -27,7 +27,7 @@ var rootCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedMulti(includeExcludeLists, kubeconfig, resourceNames) + kor.GetUnusedMulti(includeExcludeLists, kubeconfig, resourceNames, slackOpts) } } else { fmt.Printf("Subcommand %q was not found, try using 'kor --help' for available subcommands", args[0]) @@ -39,6 +39,7 @@ var ( outputFormat string kubeconfig string includeExcludeLists kor.IncludeExcludeLists + slackOpts kor.SlackOpts ) func Execute() { @@ -46,6 +47,9 @@ func Execute() { rootCmd.PersistentFlags().StringVarP(&includeExcludeLists.IncludeListStr, "include-namespaces", "n", "", "Namespaces to run on, splited by comma. Example: --include-namespace ns1,ns2,ns3. ") rootCmd.PersistentFlags().StringVarP(&includeExcludeLists.ExcludeListStr, "exclude-namespaces", "e", "", "Namespaces to be excluded, splited by comma. Example: --exclude-namespace ns1,ns2,ns3. If --include-namespace is set, --exclude-namespaces will be ignored.") rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format (table or json)") + rootCmd.PersistentFlags().StringVar(&slackOpts.WebhookURL, "slack-webhook-url", "", "Slack webhook URL to send notifications to") + rootCmd.PersistentFlags().StringVar(&slackOpts.Channel, "slack-channel", "", "Slack channel to send notifications to. --slack-channel requires --slack-auth-token to be set.") + rootCmd.PersistentFlags().StringVar(&slackOpts.Token, "slack-auth-token", "", "Slack auth token to send notifications to. --slack-auth-token requires --slack-channel to be set.") if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error while executing your CLI '%s'", err) os.Exit(1) diff --git a/cmd/kor/secrets.go b/cmd/kor/secrets.go index fae67f76..71397288 100644 --- a/cmd/kor/secrets.go +++ b/cmd/kor/secrets.go @@ -21,7 +21,7 @@ var secretCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedSecrets(includeExcludeLists, clientset) + kor.GetUnusedSecrets(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/serviceaccounts.go b/cmd/kor/serviceaccounts.go index 3b2472d5..bdeca3db 100644 --- a/cmd/kor/serviceaccounts.go +++ b/cmd/kor/serviceaccounts.go @@ -21,7 +21,7 @@ var serviceAccountCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedServiceAccounts(includeExcludeLists, clientset) + kor.GetUnusedServiceAccounts(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/services.go b/cmd/kor/services.go index d0274952..d04ba6ba 100644 --- a/cmd/kor/services.go +++ b/cmd/kor/services.go @@ -21,7 +21,7 @@ var serviceCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedServices(includeExcludeLists, clientset) + kor.GetUnusedServices(includeExcludeLists, clientset, slackOpts) } }, diff --git a/cmd/kor/statefulsets.go b/cmd/kor/statefulsets.go index 130da02c..e8ffb14f 100644 --- a/cmd/kor/statefulsets.go +++ b/cmd/kor/statefulsets.go @@ -21,7 +21,7 @@ var stsCmd = &cobra.Command{ fmt.Println(response) } } else { - kor.GetUnusedStatefulSets(includeExcludeLists, clientset) + kor.GetUnusedStatefulSets(includeExcludeLists, clientset, slackOpts) } }, diff --git a/pkg/kor/all.go b/pkg/kor/all.go index 5952b20c..abbd0842 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "encoding/json" "fmt" "os" @@ -118,8 +119,11 @@ func getUnusedPdbs(clientset kubernetes.Interface, namespace string) ResourceDif return namespacePdbDiff } -func GetUnusedAll(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedAll(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { var allDiffs []ResourceDiff namespaceCMDiff := getUnusedCMs(clientset, namespace) @@ -144,9 +148,20 @@ func GetUnusedAll(includeExcludeLists IncludeExcludeLists, clientset kubernetes. allDiffs = append(allDiffs, namespaceIngressDiff) namespacePdbDiff := getUnusedPdbs(clientset, namespace) allDiffs = append(allDiffs, namespacePdbDiff) + output := FormatOutputAll(namespace, allDiffs) - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/confimgmaps.go b/pkg/kor/confimgmaps.go index 64ca796c..8125e3c1 100644 --- a/pkg/kor/confimgmaps.go +++ b/pkg/kor/confimgmaps.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -128,9 +129,11 @@ func processNamespaceCM(clientset kubernetes.Interface, namespace string) ([]str } -func GetUnusedConfigmaps(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedConfigmaps(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceCM(clientset, namespace) if err != nil { @@ -138,8 +141,18 @@ func GetUnusedConfigmaps(includeExcludeLists IncludeExcludeLists, clientset kube continue } output := FormatOutput(namespace, diff, "Config Maps") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/deployments.go b/pkg/kor/deployments.go index 3285ea7b..c5d84f22 100644 --- a/pkg/kor/deployments.go +++ b/pkg/kor/deployments.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -32,9 +33,11 @@ func ProcessNamespaceDeployments(clientset kubernetes.Interface, namespace strin return deploymentsWithoutReplicas, nil } -func GetUnusedDeployments(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedDeployments(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := ProcessNamespaceDeployments(clientset, namespace) if err != nil { @@ -42,8 +45,18 @@ func GetUnusedDeployments(includeExcludeLists IncludeExcludeLists, clientset kub continue } output := FormatOutput(namespace, diff, "Deployments") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/hpas.go b/pkg/kor/hpas.go index 635fb7ef..7fc4bf17 100644 --- a/pkg/kor/hpas.go +++ b/pkg/kor/hpas.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -79,9 +80,11 @@ func processNamespaceHpas(clientset kubernetes.Interface, namespace string) ([]s return unusedHpas, nil } -func GetUnusedHpas(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedHpas(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceHpas(clientset, namespace) if err != nil { @@ -89,10 +92,19 @@ func GetUnusedHpas(includeExcludeLists IncludeExcludeLists, clientset kubernetes continue } output := FormatOutput(namespace, diff, "Hpas") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") } + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) + } } func GetUnusedHpasStructured(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, outputFormat string) (string, error) { diff --git a/pkg/kor/ingresses.go b/pkg/kor/ingresses.go index b657dc46..22dbf8ab 100644 --- a/pkg/kor/ingresses.go +++ b/pkg/kor/ingresses.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -88,9 +89,11 @@ func processNamespaceIngresses(clientset kubernetes.Interface, namespace string) } -func GetUnusedIngresses(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedIngresses(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceIngresses(clientset, namespace) if err != nil { @@ -98,8 +101,18 @@ func GetUnusedIngresses(includeExcludeLists IncludeExcludeLists, clientset kuber continue } output := FormatOutput(namespace, diff, "Ingresses") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go index 9caee0ee..cb85f07c 100644 --- a/pkg/kor/multi.go +++ b/pkg/kor/multi.go @@ -1,8 +1,10 @@ package kor import ( + "bytes" "encoding/json" "fmt" + "os" "strings" "k8s.io/client-go/kubernetes" @@ -53,10 +55,12 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re return allDiffs } -func GetUnusedMulti(includeExcludeLists IncludeExcludeLists, kubeconfig, resourceNames string) { +func GetUnusedMulti(includeExcludeLists IncludeExcludeLists, kubeconfig, resourceNames string, slackOpts SlackOpts) { var clientset kubernetes.Interface var namespaces []string + var outputBuffer bytes.Buffer + clientset = GetKubeClient(kubeconfig) resourceList := strings.Split(resourceNames, ",") @@ -65,8 +69,18 @@ func GetUnusedMulti(includeExcludeLists IncludeExcludeLists, kubeconfig, resourc for _, namespace := range namespaces { allDiffs := retrieveNamespaceDiffs(clientset, namespace, resourceList) output := FormatOutputAll(namespace, allDiffs) - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/pdbs.go b/pkg/kor/pdbs.go index b0458a0b..94075033 100644 --- a/pkg/kor/pdbs.go +++ b/pkg/kor/pdbs.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -48,9 +49,11 @@ func processNamespacePdbs(clientset kubernetes.Interface, namespace string) ([]s return unusedPdbs, nil } -func GetUnusedPdbs(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedPdbs(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespacePdbs(clientset, namespace) if err != nil { @@ -58,8 +61,18 @@ func GetUnusedPdbs(includeExcludeLists IncludeExcludeLists, clientset kubernetes continue } output := FormatOutput(namespace, diff, "Pdbs") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/pvc.go b/pkg/kor/pvc.go index 43035664..b5a9b130 100644 --- a/pkg/kor/pvc.go +++ b/pkg/kor/pvc.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -53,9 +54,11 @@ func processNamespacePvcs(clientset kubernetes.Interface, namespace string) ([]s return diff, nil } -func GetUnusedPvcs(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedPvcs(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespacePvcs(clientset, namespace) if err != nil { @@ -63,10 +66,19 @@ func GetUnusedPvcs(includeExcludeLists IncludeExcludeLists, clientset kubernetes continue } output := FormatOutput(namespace, diff, "Pvcs") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") } + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) + } } func GetUnusedPvcsStructured(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, outputFormat string) (string, error) { diff --git a/pkg/kor/roles.go b/pkg/kor/roles.go index 93ddc555..7bfb26db 100644 --- a/pkg/kor/roles.go +++ b/pkg/kor/roles.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -66,9 +67,11 @@ func processNamespaceRoles(clientset kubernetes.Interface, namespace string) ([] } -func GetUnusedRoles(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedRoles(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceRoles(clientset, namespace) if err != nil { @@ -76,8 +79,18 @@ func GetUnusedRoles(includeExcludeLists IncludeExcludeLists, clientset kubernete continue } output := FormatOutput(namespace, diff, "Roles") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/secrets.go b/pkg/kor/secrets.go index 9a421336..e41a748c 100644 --- a/pkg/kor/secrets.go +++ b/pkg/kor/secrets.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -140,9 +141,11 @@ func processNamespaceSecret(clientset kubernetes.Interface, namespace string) ([ } -func GetUnusedSecrets(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedSecrets(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceSecret(clientset, namespace) if err != nil { @@ -150,8 +153,18 @@ func GetUnusedSecrets(includeExcludeLists IncludeExcludeLists, clientset kuberne continue } output := FormatOutput(namespace, diff, "Secrets") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/serviceaccounts.go b/pkg/kor/serviceaccounts.go index b7e90a61..e0310255 100644 --- a/pkg/kor/serviceaccounts.go +++ b/pkg/kor/serviceaccounts.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -139,9 +140,11 @@ func processNamespaceSA(clientset kubernetes.Interface, namespace string) ([]str } -func GetUnusedServiceAccounts(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedServiceAccounts(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := processNamespaceSA(clientset, namespace) if err != nil { @@ -149,8 +152,18 @@ func GetUnusedServiceAccounts(includeExcludeLists IncludeExcludeLists, clientset continue } output := FormatOutput(namespace, diff, "ServiceAccount") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/services.go b/pkg/kor/services.go index 14a2cb86..ca0059aa 100644 --- a/pkg/kor/services.go +++ b/pkg/kor/services.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -32,9 +33,11 @@ func ProcessNamespaceServices(clientset kubernetes.Interface, namespace string) return endpointsWithoutSubsets, nil } -func GetUnusedServices(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedServices(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := ProcessNamespaceServices(clientset, namespace) if err != nil { @@ -42,8 +45,18 @@ func GetUnusedServices(includeExcludeLists IncludeExcludeLists, clientset kubern continue } output := FormatOutput(namespace, diff, "Services") - fmt.Println(output) - fmt.Println() + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } } diff --git a/pkg/kor/slack.go b/pkg/kor/slack.go new file mode 100644 index 00000000..2eb8c926 --- /dev/null +++ b/pkg/kor/slack.go @@ -0,0 +1,112 @@ +package kor + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +type SendMessageToSlack interface { + SendToSlack(slackOpts SlackOpts, outputBuffer string) error +} + +type SlackOpts struct { + WebhookURL string + Channel string + Token string +} + +type SlackMessage struct { +} + +func SendToSlack(sm SendMessageToSlack, slackOpts SlackOpts, outputBuffer string) error { + return sm.SendToSlack(slackOpts, outputBuffer) +} + +func (sm SlackMessage) SendToSlack(slackOpts SlackOpts, outputBuffer string) error { + if slackOpts.WebhookURL != "" { + payload := []byte(`{"text": "` + outputBuffer + `"}`) + _, err := http.Post(slackOpts.WebhookURL, "application/json", bytes.NewBuffer(payload)) + + if err != nil { + return err + } + return nil + } else if slackOpts.Channel != "" && slackOpts.Token != "" { + fmt.Printf("Sending message to Slack channel %s...", slackOpts.Channel) + outputFilePath, _ := writeOutputToFile(outputBuffer) + + var formData bytes.Buffer + writer := multipart.NewWriter(&formData) + + fileWriter, err := writer.CreateFormFile("file", outputFilePath) + if err != nil { + return err + } + file, err := os.Open(outputFilePath) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(fileWriter, file) + if err != nil { + return err + } + + if err := writer.WriteField("channels", slackOpts.Channel); err != nil { + return err + } + + writer.Close() + + req, err := http.NewRequest("POST", "https://slack.com/api/files.upload", &formData) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+slackOpts.Token) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack API returned non-OK status code: %d", resp.StatusCode) + } + + return nil + } else { + return errors.New("SlackOpts must contain either WebhookURL or Channel and Token") + } +} + +func writeOutputToFile(outputBuffer string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user's home directory: %v", err) + } + + outputFileName := "kor-scan-results.txt" + outputFilePath := filepath.Join(homeDir, outputFileName) + + file, err := os.Create(outputFilePath) + if err != nil { + return "", fmt.Errorf("failed to create output file: %v", err) + } + defer file.Close() + + _, err = file.WriteString(outputBuffer) + if err != nil { + return "", fmt.Errorf("failed to write output to file: %v", err) + } + + return outputFilePath, nil +} diff --git a/pkg/kor/slack_test.go b/pkg/kor/slack_test.go new file mode 100644 index 00000000..1875b92e --- /dev/null +++ b/pkg/kor/slack_test.go @@ -0,0 +1,74 @@ +package kor + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +type SendToSlackTestCase struct { + Name string + SlackOpts SlackOpts + OutputBuffer string +} + +var testCases = []SendToSlackTestCase{ + { + Name: "Test using WebhookURL", + SlackOpts: SlackOpts{ + WebhookURL: "slack.webhookurl.com", + }, + OutputBuffer: "Test message", + }, + { + Name: "Test using Channel and Token", + SlackOpts: SlackOpts{ + Channel: "your_channel", + Token: "your_token", + }, + OutputBuffer: "Test message", + }, + { + Name: "Test with empty SlackOpts", + SlackOpts: SlackOpts{}, + OutputBuffer: "Test message", + }, +} + +func TestSendToSlack(t *testing.T) { + for _, tc := range testCases { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := SendToSlack(SlackMessage{}, tc.SlackOpts, tc.OutputBuffer); err != nil { + t.Errorf("Expected no error, got %v", err) + } + })) + + defer server.Close() + } +} + +func TestWriteOutputToFile(t *testing.T) { + outputBuffer := bytes.Buffer{} + outputBuffer.WriteString("This is a test output.\n") + expectedOutput := outputBuffer.String() + + outputFilePath, err := writeOutputToFile(expectedOutput) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if _, err := os.Stat(outputFilePath); os.IsNotExist(err) { + t.Errorf("Expected output file to exist, got error: %v", err) + } + + fileContent, err := os.ReadFile(outputFilePath) + if err != nil { + t.Errorf("Failed to read output file: %v", err) + } + + if string(fileContent) != expectedOutput { + t.Errorf("Expected file content:\n%s\nGot:\n%s", expectedOutput, string(fileContent)) + } +} diff --git a/pkg/kor/statefulsets.go b/pkg/kor/statefulsets.go index 6c7102e6..ead07c44 100644 --- a/pkg/kor/statefulsets.go +++ b/pkg/kor/statefulsets.go @@ -1,6 +1,7 @@ package kor import ( + "bytes" "context" "encoding/json" "fmt" @@ -28,18 +29,30 @@ func ProcessNamespaceStatefulSets(clientset kubernetes.Interface, namespace stri return statefulSetsWithoutReplicas, nil } -func GetUnusedStatefulSets(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface) { +func GetUnusedStatefulSets(includeExcludeLists IncludeExcludeLists, clientset kubernetes.Interface, slackOpts SlackOpts) { namespaces := SetNamespaceList(includeExcludeLists, clientset) + var outputBuffer bytes.Buffer + for _, namespace := range namespaces { diff, err := ProcessNamespaceStatefulSets(clientset, namespace) if err != nil { fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err) continue } - output := FormatOutput(namespace, diff, "StatefulSets") - fmt.Println(output) - fmt.Println() + output := FormatOutput(namespace, diff, "Statefulsets") + + outputBuffer.WriteString(output) + outputBuffer.WriteString("\n") + } + + if slackOpts != (SlackOpts{}) { + if err := SendToSlack(SlackMessage{}, slackOpts, outputBuffer.String()); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send message to slack: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(outputBuffer.String()) } }