diff --git a/cmd/config.go b/cmd/config.go index 09fb2a8a..f5927682 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -17,9 +17,10 @@ limitations under the License. package cmd import ( - "os" "fmt" "io/ioutil" + "os" + "github.com/spf13/cobra" ) @@ -35,7 +36,7 @@ config command allows admin setup his own configuration for running kubewatch`, } var configAddCmd = &cobra.Command{ - Use: "add", + Use: "add", Short: "add webhook config to .kubewatch.yaml", Long: ` Adds webhook config to .kubewatch.yaml`, @@ -45,17 +46,17 @@ Adds webhook config to .kubewatch.yaml`, } var configViewCmd = &cobra.Command{ - Use: "view", + Use: "view", Short: "view .kubewatch.yaml", Long: ` display the contents of the contents of .kubewatch.yaml`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("Contents of .kubewatch.yaml") - configFile, err := ioutil.ReadFile(os.Getenv("HOME")+"/"+".kubewatch.yaml") - if err != nil { - fmt.Printf("yamlFile.Get err #%v ", err) - } - fmt.Println(string(configFile)) + configFile, err := ioutil.ReadFile(os.Getenv("HOME") + "/" + ".kubewatch.yaml") + if err != nil { + fmt.Printf("yamlFile.Get err #%v ", err) + } + fmt.Println(string(configFile)) }, } @@ -71,5 +72,6 @@ func init() { mattermostConfigCmd, flockConfigCmd, webhookConfigCmd, + msteamsConfigCmd, ) } diff --git a/cmd/msteams.go b/cmd/msteams.go new file mode 100644 index 00000000..e4be9680 --- /dev/null +++ b/cmd/msteams.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/Sirupsen/logrus" + "github.com/bitnami-labs/kubewatch/config" + "github.com/spf13/cobra" +) + +// msteamsConfigCmd represents the msteams subcommand +var msteamsConfigCmd = &cobra.Command{ + Use: "MS Teams FLAG", + Short: "specific MS Teams configuration", + Long: `specific MS Teams 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.MSTeams.WebhookURL = webhookURL + } + } else { + logrus.Fatal(err) + } + + if err = conf.Write(); err != nil { + logrus.Fatal(err) + } + }, +} + +func init() { + msteamsConfigCmd.Flags().StringP("webhookurl", "w", "", "Specify MS Teams webhook URL") +} diff --git a/config/config.go b/config/config.go index cfd5399b..75addd7e 100644 --- a/config/config.go +++ b/config/config.go @@ -33,6 +33,7 @@ type Handler struct { Mattermost Mattermost `json:"mattermost"` Flock Flock `json:"flock"` Webhook Webhook `json:"webhook"` + MSTeams MSTeams `json:"msteams"` } // Resource contains resource configuration @@ -91,6 +92,11 @@ type Webhook struct { Url string `json:"url"` } +// MSTeams contains MSTeams configuration +type MSTeams struct { + WebhookURL string `json:"webhookurl"` +} + // New creates new config object func New() (*Config, error) { c := &Config{} diff --git a/examples/conf/kubewatch.conf.msteams.yaml b/examples/conf/kubewatch.conf.msteams.yaml new file mode 100644 index 00000000..ed3929f4 --- /dev/null +++ b/examples/conf/kubewatch.conf.msteams.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kubewatch +data: + .kubewatch.yaml: | + namespace: + handler: + msteams: + webhookurl: https://outlook.office.com/webhook/... + resource: + namespace: false + deployment: false + replicationcontroller: false + replicaset: false + daemonset: false + services: false + pod: true + secret: false + configmap: false diff --git a/pkg/client/run.go b/pkg/client/run.go index 93931034..afaf908f 100644 --- a/pkg/client/run.go +++ b/pkg/client/run.go @@ -20,12 +20,13 @@ import ( "log" "github.com/bitnami-labs/kubewatch/config" - "github.com/bitnami-labs/kubewatch/pkg/handlers" - "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" "github.com/bitnami-labs/kubewatch/pkg/controller" + "github.com/bitnami-labs/kubewatch/pkg/handlers" + "github.com/bitnami-labs/kubewatch/pkg/handlers/flock" "github.com/bitnami-labs/kubewatch/pkg/handlers/hipchat" "github.com/bitnami-labs/kubewatch/pkg/handlers/mattermost" - "github.com/bitnami-labs/kubewatch/pkg/handlers/flock" + "github.com/bitnami-labs/kubewatch/pkg/handlers/msteam" + "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" "github.com/bitnami-labs/kubewatch/pkg/handlers/webhook" ) @@ -43,6 +44,8 @@ func Run(conf *config.Config) { eventHandler = new(flock.Flock) case len(conf.Handler.Webhook.Url) > 0: eventHandler = new(webhook.Webhook) + case len(conf.Handler.MSTeams.WebhookURL) > 0: + eventHandler = new(msteam.MSTeams) default: eventHandler = new(handlers.Default) } diff --git a/pkg/handlers/handler.go b/pkg/handlers/handler.go index aab5fd4e..318c69d2 100644 --- a/pkg/handlers/handler.go +++ b/pkg/handlers/handler.go @@ -18,10 +18,11 @@ package handlers import ( "github.com/bitnami-labs/kubewatch/config" - "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" + "github.com/bitnami-labs/kubewatch/pkg/handlers/flock" "github.com/bitnami-labs/kubewatch/pkg/handlers/hipchat" "github.com/bitnami-labs/kubewatch/pkg/handlers/mattermost" - "github.com/bitnami-labs/kubewatch/pkg/handlers/flock" + "github.com/bitnami-labs/kubewatch/pkg/handlers/msteam" + "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" "github.com/bitnami-labs/kubewatch/pkg/handlers/webhook" ) @@ -36,12 +37,13 @@ type Handler interface { // Map maps each event handler function to a name for easily lookup var Map = map[string]interface{}{ - "default": &Default{}, - "slack": &slack.Slack{}, - "hipchat": &hipchat.Hipchat{}, + "default": &Default{}, + "slack": &slack.Slack{}, + "hipchat": &hipchat.Hipchat{}, "mattermost": &mattermost.Mattermost{}, - "flock": &flock.Flock{}, - "webhook": &webhook.Webhook{}, + "flock": &flock.Flock{}, + "webhook": &webhook.Webhook{}, + "ms-teams": &msteam.MSTeams{}, } // Default handler implements Handler interface, diff --git a/pkg/handlers/msteam/msteam.go b/pkg/handlers/msteam/msteam.go new file mode 100644 index 00000000..f54a2ec4 --- /dev/null +++ b/pkg/handlers/msteam/msteam.go @@ -0,0 +1,169 @@ +/* +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 msteam + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + + "github.com/bitnami-labs/kubewatch/config" + "github.com/bitnami-labs/kubewatch/pkg/event" +) + +var msteamsErrMsg = ` +%s + +You need to set the MS teams webhook URL, +using --webhookURL, or using environment variables: + +export KW_MSTEAMS_WEBHOOKURL=webhook_url + +Command line flags will override environment variables + +` + +var msTeamsColors = map[string]string{ + "Normal": "2DC72D", + "Warning": "DEFF22", + "Danger": "8C1A1A", +} + +// Constants for Sending a Card +const ( + messageType = "MessageCard" + context = "http://schema.org/extensions" +) + +// TeamsMessageCard is for the Card Fields to send in Teams +// The Documentation is in https://docs.microsoft.com/en-us/outlook/actionable-messages/card-reference#card-fields +type TeamsMessageCard struct { + Type string `json:"@type"` + Context string `json:"@context"` + ThemeColor string `json:"themeColor"` + Summary string `json:"summary"` + Title string `json:"title"` + Text string `json:"text,omitempty"` + Sections []TeamsMessageCardSection `json:"sections"` +} + +// TeamsMessageCardSection is placed under TeamsMessageCard.Sections +// Each element of AlertWebHook.Alerts will the number of elements of TeamsMessageCard.Sections to create +type TeamsMessageCardSection struct { + ActivityTitle string `json:"activityTitle"` + Facts []TeamsMessageCardSectionFacts `json:"facts"` + Markdown bool `json:"markdown"` +} + +// TeamsMessageCardSectionFacts is placed under TeamsMessageCardSection.Facts +type TeamsMessageCardSectionFacts struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Default handler implements Handler interface, +// print each event with JSON format +type MSTeams struct { + // TeamsWebhookURL is the webhook url of the Teams connector + TeamsWebhookURL string +} + +// sendCard sends the JSON Encoded TeamsMessageCard to the webhook URL +func sendCard(ms *MSTeams, card *TeamsMessageCard) (*http.Response, error) { + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(card); err != nil { + return nil, fmt.Errorf("Failed encoding message card: %v", err) + } + res, err := http.Post(ms.TeamsWebhookURL, "application/json", buffer) + if err != nil { + return nil, fmt.Errorf("Failed sending to webhook url %s. Got the error: %v", + ms.TeamsWebhookURL, err) + } + if res.StatusCode != http.StatusOK { + resMessage, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("Failed reading Teams http response: %v", err) + } + return nil, fmt.Errorf("Failed sending to the Teams Channel. Teams http response: %s, %s", + res.Status, string(resMessage)) + } + if err := res.Body.Close(); err != nil { + return nil, err + } + return res, nil +} + +// notifyMSTeams creates the TeamsMessageCard and send to webhook URL +func notifyMSTeams(ms *MSTeams, obj interface{}, action string) { + card := &TeamsMessageCard{ + Type: messageType, + Context: context, + Title: fmt.Sprintf("kubewatch"), + // Set a default Summary, this is required for Microsoft Teams + Summary: "kubewatch notification received", + } + + e := event.New(obj, action) + card.ThemeColor = msTeamsColors[e.Status] + + var s TeamsMessageCardSection + s.ActivityTitle = e.Message() + s.Markdown = true + card.Sections = append(card.Sections, s) + + if _, err := sendCard(ms, card); err != nil { + log.Printf("%s\n", err) + return + } + + log.Printf("Message successfully sent to MS Teams") +} + +// Init initializes handler configuration +func (ms *MSTeams) Init(c *config.Config) error { + webhookURL := c.Handler.MSTeams.WebhookURL + + if webhookURL == "" { + webhookURL = os.Getenv("KW_MSTEAMS_WEBHOOKURL") + } + + if webhookURL == "" { + return fmt.Errorf(msteamsErrMsg, "Missing MS teams webhook URL") + } + + ms.TeamsWebhookURL = webhookURL + return nil +} + +// Notify on object creation +func (ms *MSTeams) ObjectCreated(obj interface{}) { + notifyMSTeams(ms, obj, "created") +} + +// Notify on object deletion +func (ms *MSTeams) ObjectDeleted(obj interface{}) { + notifyMSTeams(ms, obj, "deleted") +} + +// Notify on object update +func (ms *MSTeams) ObjectUpdated(oldObj, newObj interface{}) { + notifyMSTeams(ms, oldObj, "updated") +} diff --git a/pkg/handlers/msteam/msteam_test.go b/pkg/handlers/msteam/msteam_test.go new file mode 100644 index 00000000..ece31181 --- /dev/null +++ b/pkg/handlers/msteam/msteam_test.go @@ -0,0 +1,191 @@ +package msteam + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/bitnami-labs/kubewatch/config" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Tests the Init() function +func TestInit(t *testing.T) { + s := &MSTeams{} + expectedError := fmt.Errorf(msteamsErrMsg, "Missing MS teams webhook URL") + + var Tests = []struct { + ms config.MSTeams + err error + }{ + {config.MSTeams{WebhookURL: "somepath"}, nil}, + {config.MSTeams{}, expectedError}, + } + + for _, tt := range Tests { + c := &config.Config{} + c.Handler.MSTeams = tt.ms + if err := s.Init(c); !reflect.DeepEqual(err, tt.err) { + t.Fatalf("Init(): %v", err) + } + } +} + +// Tests ObjectCreated() by passing v1.Pod +func TestObjectCreated(t *testing.T) { + expectedCard := TeamsMessageCard{ + Type: messageType, + Context: context, + ThemeColor: msTeamsColors["Normal"], + Summary: "kubewatch notification received", + Title: "kubewatch", + Text: "", + Sections: []TeamsMessageCardSection{ + TeamsMessageCardSection{ + ActivityTitle: "A `pod` in namespace `new` has been `created`:\n`foo`", + Markdown: true, + }, + }, + } + + 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 TeamsMessageCard + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedCard) { + t.Errorf("expected %v, got %v", expectedCard, c) + } + })) + + ms := &MSTeams{TeamsWebhookURL: ts.URL} + p := &v1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + UID: "12345678", + Name: "foo", + Namespace: "new", + }, + } + ms.ObjectCreated(p) +} + +// Tests ObjectDeleted() by passing v1.Pod +func TestObjectDeleted(t *testing.T) { + expectedCard := TeamsMessageCard{ + Type: messageType, + Context: context, + ThemeColor: msTeamsColors["Danger"], + Summary: "kubewatch notification received", + Title: "kubewatch", + Text: "", + Sections: []TeamsMessageCardSection{ + TeamsMessageCardSection{ + ActivityTitle: "A `pod` in namespace `new` has been `deleted`:\n`foo`", + Markdown: true, + }, + }, + } + + 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 TeamsMessageCard + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedCard) { + t.Errorf("expected %v, got %v", expectedCard, c) + } + })) + + ms := &MSTeams{TeamsWebhookURL: ts.URL} + p := &v1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + UID: "12345678", + Name: "foo", + Namespace: "new", + }, + } + ms.ObjectDeleted(p) +} + +// Tests ObjectUpdated() by passing v1.Pod +func TestObjectUpdated(t *testing.T) { + expectedCard := TeamsMessageCard{ + Type: messageType, + Context: context, + ThemeColor: msTeamsColors["Warning"], + Summary: "kubewatch notification received", + Title: "kubewatch", + Text: "", + Sections: []TeamsMessageCardSection{ + TeamsMessageCardSection{ + ActivityTitle: "A `pod` in namespace `new` has been `updated`:\n`foo`", + Markdown: true, + }, + }, + } + + 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 TeamsMessageCard + if err := decoder.Decode(&c); err != nil { + t.Errorf("%v", err) + } + if !reflect.DeepEqual(c, expectedCard) { + t.Errorf("expected %v, got %v", expectedCard, c) + } + })) + + ms := &MSTeams{TeamsWebhookURL: ts.URL} + + oldP := &v1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + UID: "12345678", + Name: "foo", + Namespace: "new", + }, + } + + newP := &v1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + UID: "12345678", + Name: "foo-new", + Namespace: "new", + }, + } + + ms.ObjectUpdated(oldP, newP) +}