diff --git a/cmd/config.go b/cmd/config.go index edfa564b..9f959368 100755 --- a/cmd/config.go +++ b/cmd/config.go @@ -122,5 +122,6 @@ func init() { msteamsConfigCmd, smtpConfigCmd, larkConfigCmd, + discordConfigCmd, ) } diff --git a/cmd/discord.go b/cmd/discord.go new file mode 100644 index 00000000..e7b08f2f --- /dev/null +++ b/cmd/discord.go @@ -0,0 +1,53 @@ +/* +Copyright 2016 Skippbox, Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/bitnami-labs/kubewatch/config" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// discordConfigCmd represents the msteams subcommand +var discordConfigCmd = &cobra.Command{ + Use: "discord", + Short: "specific discord configuration", + Long: `specific discord configuration`, + Run: func(cmd *cobra.Command, args []string) { + conf, err := config.New() + if err != nil { + logrus.Fatal(err) + } + + webhookURL, err := cmd.Flags().GetString("webhookurl") + if err == nil { + if len(webhookURL) > 0 { + conf.Handler.Discord.WebhookURL = webhookURL + } + } else { + logrus.Fatal(err) + } + + if err = conf.Write(); err != nil { + logrus.Fatal(err) + } + }, +} + +func init() { + discordConfigCmd.Flags().StringP("webhookurl", "w", "", "Specify Discord webhook URL") +} diff --git a/cmd/root.go b/cmd/root.go index 0295d1eb..080aa3ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,12 +21,13 @@ import ( "net/http" "os" + _ "net/http/pprof" + "github.com/bitnami-labs/kubewatch/config" c "github.com/bitnami-labs/kubewatch/pkg/client" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - _ "net/http/pprof" ) var cfgFile string @@ -46,9 +47,11 @@ supported webhooks: - slack - hipchat - mattermost + - msteams - flock - webhook - lark + - discord `, Run: func(cmd *cobra.Command, args []string) { diff --git a/config/config.go b/config/config.go index 47d3ad20..b4401a46 100755 --- a/config/config.go +++ b/config/config.go @@ -47,6 +47,7 @@ type Handler struct { MSTeams MSTeams `json:"msteams"` SMTP SMTP `json:"smtp"` Lark Lark `json:"lark"` + Discord Discord `json:"discord"` } // Resource contains resource configuration @@ -197,6 +198,10 @@ type SMTPAuth struct { Secret string `json:"secret" yaml:"secret,omitempty"` } +type Discord struct { + WebhookURL string `json:"webhookurl"` +} + // New creates new config object func New() (*Config, error) { c := &Config{} diff --git a/config/config_test.go b/config/config_test.go index f18bae3c..a161f3e9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -16,11 +16,9 @@ limitations under the License. package config -import ( -//"io/ioutil" -//"os" -//"testing" -) +// "io/ioutil" +// "os" +// "testing" var configStr = ` { @@ -50,7 +48,7 @@ var configStr = ` //func TestLoadOK(t *testing.T) { // content := []byte(configStr) -// tmpConfigFile, err := ioutil.TempFile(homeDir(), "kubewatch") +// tmpConfigFile, err := os.CreateTemp(homeDir(), "kubewatch") // if err != nil { // t.Fatalf("TestLoad(): %+v", err) // } diff --git a/config/sample.go b/config/sample.go index 5543b4a3..cd1a266b 100644 --- a/config/sample.go +++ b/config/sample.go @@ -36,6 +36,8 @@ handler: msteams: # MSTeams API Webhook URL. webhookurl: "" + discord: + webhookurl: "" smtp: # Destination e-mail address. to: "" diff --git a/docs/design.md b/docs/design.md index a66de0bd..2a34449c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -38,6 +38,7 @@ With each event get from k8s and matched filtering from configuration, it is pas - `Slack`: which send notification to Slack channel based on information from config - `Smtp`: which sends notifications to email recipients using a SMTP server obtained from config - `Lark`: which sends notifications to Lark incoming webhook based on information from config + - `Discord`: which sends notifications to Discord channel based on information from config More handlers will be added in future. diff --git a/examples/conf/kubewatch.conf.discord.yaml b/examples/conf/kubewatch.conf.discord.yaml new file mode 100644 index 00000000..7d95d42d --- /dev/null +++ b/examples/conf/kubewatch.conf.discord.yaml @@ -0,0 +1,23 @@ +### why add query wait=true? +## follow docs: https://discord.com/developers/docs/resources/webhook#execute-webhook + +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubewatch +data: + .kubewatch.yaml: | + namespace: + handler: + discord: + webhookurl: "?wait=true" + resource: + namespace: false + deployment: false + replicationcontroller: false + replicaset: false + daemonset: false + services: false + pod: true + secret: false + configmap: false diff --git a/go.mod b/go.mod index f0d0739e..5b3adbd8 100755 --- a/go.mod +++ b/go.mod @@ -1,22 +1,16 @@ module github.com/bitnami-labs/kubewatch -go 1.14 +go 1.21 require ( github.com/fatih/structtag v1.2.0 - github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect - github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/magiconair/properties v1.7.4 // indirect github.com/mkmik/multierror v0.3.0 github.com/pelletier/go-toml v1.0.1 // indirect github.com/prometheus/client_golang v1.20.3 github.com/segmentio/textio v1.2.0 github.com/sirupsen/logrus v1.6.0 github.com/slack-go/slack v0.6.5 - github.com/spf13/cast v1.1.0 // indirect github.com/spf13/cobra v0.0.1 - github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec // indirect github.com/spf13/viper v1.0.0 github.com/tbruyelle/hipchat-go v0.0.0-20160921153256-749fb9e14beb gopkg.in/yaml.v3 v3.0.1 @@ -24,3 +18,49 @@ require ( k8s.io/apimachinery v0.20.15 k8s.io/client-go v0.20.15 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-logr/logr v0.2.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.4.3 // indirect + github.com/google/go-cmp v0.5.2 // indirect + github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect + github.com/google/gofuzz v1.1.0 // indirect + github.com/googleapis/gnostic v0.4.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect + github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb // indirect + github.com/imdario/mergo v0.3.5 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/json-iterator/go v1.1.10 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect + github.com/magiconair/properties v1.7.4 // indirect + github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pelletier/go-toml v1.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/cast v1.1.0 // indirect + github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect + google.golang.org/appengine v1.6.5 // indirect + google.golang.org/protobuf v1.25.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect + k8s.io/klog/v2 v2.4.0 // indirect + k8s.io/utils v0.0.0-20201110183641-67b214c5f920 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect + sigs.k8s.io/yaml v1.2.0 // indirect +) diff --git a/pkg/client/run.go b/pkg/client/run.go index 34a248d8..1c4987ec 100755 --- a/pkg/client/run.go +++ b/pkg/client/run.go @@ -25,6 +25,7 @@ import ( "github.com/bitnami-labs/kubewatch/pkg/controller" "github.com/bitnami-labs/kubewatch/pkg/handlers" "github.com/bitnami-labs/kubewatch/pkg/handlers/cloudevent" + "github.com/bitnami-labs/kubewatch/pkg/handlers/discord" "github.com/bitnami-labs/kubewatch/pkg/handlers/flock" "github.com/bitnami-labs/kubewatch/pkg/handlers/hipchat" "github.com/bitnami-labs/kubewatch/pkg/handlers/lark" @@ -81,6 +82,8 @@ func ParseEventHandler(conf *config.Config) handlers.Handler { eventHandler = new(smtp.SMTP) case len(conf.Handler.Lark.WebhookURL) > 0: eventHandler = new(lark.Webhook) + case len(conf.Handler.Discord.WebhookURL) > 0: + eventHandler = new(discord.Discord) default: eventHandler = new(handlers.Default) } diff --git a/pkg/handlers/discord/discord.go b/pkg/handlers/discord/discord.go new file mode 100644 index 00000000..5ded38cc --- /dev/null +++ b/pkg/handlers/discord/discord.go @@ -0,0 +1,123 @@ +/* +Copyright 2016 Skippbox, Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discord + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/bitnami-labs/kubewatch/config" + "github.com/bitnami-labs/kubewatch/pkg/event" + "github.com/bitnami-labs/kubewatch/pkg/handlers" + "github.com/sirupsen/logrus" +) + +var dcErrMsg = ` +%s + +You need to set the MS teams webhook URL, +using --webhookURL, or using environment variables: + +export KW_DISCORD_WEBHOOKURL=webhook_url + +Command line flags will override environment variables + +` +var dcColors = map[string]int{ + "Normal": 8311585, + "Warning": 16312092, + "Danger": 13632027, +} + +type Discord struct { + DcWebhookURL string +} + +type DiscordMsg struct { + Embeds []DiscordEmbed `json:"embeds"` +} + +type DiscordEmbed struct { + Color int `json:"color"` + Title string `json:"title"` +} + +var _ handlers.Handler = &Discord{} + +func (dc *Discord) Init(c *config.Config) error { + webhookURL := c.Handler.Discord.WebhookURL + + if webhookURL == "" { + webhookURL = os.Getenv("KW_DISCORD_WEBHOOKURL") + } + + if webhookURL == "" { + return fmt.Errorf(dcErrMsg, "Missing Discord webhook URL") + } + + dc.DcWebhookURL = webhookURL + return nil +} + +func (dc *Discord) Handle(e event.Event) { + msg := &DiscordMsg{} + + var embed DiscordEmbed + embed.Color = dcColors[e.Status] + embed.Title = e.Message() + + msg.Embeds = append(msg.Embeds, embed) + + _, err := sendMessage(dc, msg) + if err != nil { + logrus.Printf("%s\n", err) + return + } + + logrus.Printf("Message successfully sent to Discord") +} + +func sendMessage(dc *Discord, discordMsg *DiscordMsg) (*http.Response, error) { + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(discordMsg); err != nil { + return nil, fmt.Errorf("Failed encoding message: %v", err) + } + + res, err := http.Post(dc.DcWebhookURL, "application/json", buffer) + if err != nil { + return nil, fmt.Errorf("Failed sending to webhook url %s. Got the error: %v", dc.DcWebhookURL, err) + } + + if res.StatusCode != http.StatusOK { + resMessage, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("Failed reading Discord http response: %v", err) + } + return nil, fmt.Errorf("Failed sending to Discord channel. Discord http response: %s, %s", + res.Status, string(resMessage)) + } + + if err := res.Body.Close(); err != nil { + return nil, err + } + + return res, nil +} diff --git a/pkg/handlers/discord/discord_test.go b/pkg/handlers/discord/discord_test.go new file mode 100644 index 00000000..880e7255 --- /dev/null +++ b/pkg/handlers/discord/discord_test.go @@ -0,0 +1,158 @@ +package discord + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/bitnami-labs/kubewatch/config" + "github.com/bitnami-labs/kubewatch/pkg/event" +) + +func TestInit(t *testing.T) { + s := &Discord{} + expectedError := fmt.Errorf(dcErrMsg, "Missing Discord webhook URL") + + var Tests = []struct { + ms config.Discord + err error + }{ + {config.Discord{WebhookURL: "somepath"}, nil}, + {config.Discord{}, expectedError}, + } + + for _, tt := range Tests { + c := &config.Config{} + c.Handler.Discord = tt.ms + if err := s.Init(c); !reflect.DeepEqual(err, tt.err) { + t.Fatalf("Init(): %v", err) + } + } +} + +func TestObjectCreated(t *testing.T) { + expectedDiscordMsg := DiscordMsg{ + Embeds: []DiscordEmbed{ + { + Color: dcColors["Normal"], + Title: "A `pod` in namespace `new` has been `Created`:\n`foo`", + }, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if r.Method != "POST" { + t.Errorf("expected a POST request for ObjectCreated()") + } + decoder := json.NewDecoder(r.Body) + var c DiscordMsg + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedDiscordMsg) { + t.Errorf("expected %v, got %v", expectedDiscordMsg, c) + } + })) + + ms := &Discord{DcWebhookURL: ts.URL} + p := event.Event{ + Name: "foo", + Kind: "pod", + Namespace: "new", + Reason: "Created", + Status: "Normal", + } + + ms.Handle(p) +} + +// Tests ObjectDeleted() by passing v1.Pod +func TestObjectDeleted(t *testing.T) { + expectedDiscordMsg := DiscordMsg{ + Embeds: []DiscordEmbed{ + { + Color: dcColors["Danger"], + Title: "A `pod` in namespace `new` has been `Deleted`:\n`foo`", + }, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if r.Method != "POST" { + t.Errorf("expected a POST request for ObjectDeleted()") + } + decoder := json.NewDecoder(r.Body) + var c DiscordMsg + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedDiscordMsg) { + t.Errorf("expected %v, got %v", expectedDiscordMsg, c) + } + })) + + ms := &Discord{DcWebhookURL: ts.URL} + + p := event.Event{ + Name: "foo", + Namespace: "new", + Kind: "pod", + Reason: "Deleted", + Status: "Danger", + } + + ms.Handle(p) +} + +// Tests ObjectUpdated() by passing v1.Pod +func TestObjectUpdated(t *testing.T) { + expectedDiscordMsg := DiscordMsg{ + Embeds: []DiscordEmbed{ + { + Color: dcColors["Warning"], + Title: "A `pod` in namespace `new` has been `Updated`:\n`foo`", + }, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if r.Method != "POST" { + t.Errorf("expected a POST request for ObjectUpdated()") + } + decoder := json.NewDecoder(r.Body) + var c DiscordMsg + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedDiscordMsg) { + t.Errorf("expected %v, got %v", expectedDiscordMsg, c) + } + })) + + ms := &Discord{DcWebhookURL: ts.URL} + + oldP := event.Event{ + Name: "foo", + Namespace: "new", + Kind: "pod", + Reason: "Updated", + Status: "Warning", + } + + newP := event.Event{ + Name: "foo-new", + Namespace: "new", + Kind: "pod", + Reason: "Updated", + Status: "Warning", + } + _ = newP + + ms.Handle(oldP) +} diff --git a/pkg/handlers/msteam/msteam.go b/pkg/handlers/msteam/msteam.go index 241a6545..3dfd7040 100644 --- a/pkg/handlers/msteam/msteam.go +++ b/pkg/handlers/msteam/msteam.go @@ -20,11 +20,12 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/sirupsen/logrus" "io" "net/http" "os" + "github.com/sirupsen/logrus" + "github.com/bitnami-labs/kubewatch/config" "github.com/bitnami-labs/kubewatch/pkg/event" ) diff --git a/pkg/handlers/slackwebhook/slackwebhook_test.go b/pkg/handlers/slackwebhook/slackwebhook_test.go index f5d85143..4578af70 100755 --- a/pkg/handlers/slackwebhook/slackwebhook_test.go +++ b/pkg/handlers/slackwebhook/slackwebhook_test.go @@ -37,12 +37,12 @@ func TestWebhookInit(t *testing.T) { slackwebhook config.SlackWebhook err error }{ - {config.SlackWebhook{Channel: "foo", Username: "bar", Slackwebhookurl: "you"}, nil}, - {config.SlackWebhook{Channel: "foo"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook Username")}, - {config.SlackWebhook{Username: "bar"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook Channel")}, - {config.SlackWebhook{Emoji: ":kubernetes:"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook Channel")}, - {config.SlackWebhook{Slackwebhookurl: "you"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook Channel")}, - {config.SlackWebhook{}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook Channel")}, + {config.SlackWebhook{Channel: "foo", Username: "bar", Slackwebhookurl: "somepath"}, nil}, + {config.SlackWebhook{Channel: "foo"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook url")}, + {config.SlackWebhook{Username: "bar"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook url")}, + {config.SlackWebhook{Emoji: ":kubernetes:"}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook url")}, + {config.SlackWebhook{Slackwebhookurl: "somepath"}, nil}, + {config.SlackWebhook{}, fmt.Errorf(webhookErrMsg, "Missing Slack Webhook url")}, } for _, tt := range Tests { diff --git a/pkg/handlers/webhook/webhook.go b/pkg/handlers/webhook/webhook.go index d20ff09d..5cdb1fdf 100644 --- a/pkg/handlers/webhook/webhook.go +++ b/pkg/handlers/webhook/webhook.go @@ -20,9 +20,10 @@ import ( "crypto/tls" "crypto/x509" "fmt" - "github.com/sirupsen/logrus" "os" + "github.com/sirupsen/logrus" + "bytes" "encoding/json" "net/http" diff --git a/pkg/utils/k8sutil.go b/pkg/utils/k8sutil.go index faffba94..f4e095aa 100644 --- a/pkg/utils/k8sutil.go +++ b/pkg/utils/k8sutil.go @@ -7,10 +7,10 @@ import ( apps_v1 "k8s.io/api/apps/v1" batch_v1 "k8s.io/api/batch/v1" api_v1 "k8s.io/api/core/v1" + events_v1 "k8s.io/api/events/v1" ext_v1beta1 "k8s.io/api/extensions/v1beta1" networking_v1 "k8s.io/api/networking/v1" rbac_v1 "k8s.io/api/rbac/v1" - events_v1 "k8s.io/api/events/v1" rbac_v1beta1 "k8s.io/api/rbac/v1beta1" meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic"