From 15b17f87e5be931485e6017a37728f45eda3f165 Mon Sep 17 00:00:00 2001 From: Leif Segen Date: Tue, 8 Sep 2020 12:39:47 -0500 Subject: [PATCH] Add option for Teams Webhook Add option to most messages via Teams / Office365 Connectors --- chart/keel/README.md | 2 + chart/keel/templates/secret.yaml | 3 + chart/keel/values.yaml | 6 + constants/constants.go | 3 + deployment/deployment-template.yaml | 3 + extension/notification/teams/manifest.json | 35 +++++ extension/notification/teams/teams.go | 145 +++++++++++++++++++++ extension/notification/teams/teams_test.go | 70 ++++++++++ 8 files changed, 267 insertions(+) create mode 100644 extension/notification/teams/manifest.json create mode 100644 extension/notification/teams/teams.go create mode 100644 extension/notification/teams/teams_test.go diff --git a/chart/keel/README.md b/chart/keel/README.md index 07bd1317..a83c1aa8 100644 --- a/chart/keel/README.md +++ b/chart/keel/README.md @@ -99,6 +99,8 @@ The following table lists has the main configurable parameters (polling, trigger | `slack.token` | Slack token | | | `slack.channel` | Slack channel | | | `slack.approvalsChannel` | Slack channel for approvals | | +| `teams.enabled` | Enable/disable MS Teams Notification | `false` | +| `teams.webhookUrl` | MS Teams Connector's webhook url | | | `service.enabled` | Enable/disable Keel service | `false` | | `service.type` | Keel service type | `LoadBalancer` | | `service.externalIP` | Keel static IP | | diff --git a/chart/keel/templates/secret.yaml b/chart/keel/templates/secret.yaml index 5f183b82..80fdde87 100644 --- a/chart/keel/templates/secret.yaml +++ b/chart/keel/templates/secret.yaml @@ -24,6 +24,9 @@ data: HIPCHAT_TOKEN: {{ .Values.hipchat.token | b64enc}} HIPCHAT_APPROVALS_PASSWORT: {{ .Values.hipchat.password | b64enc }} {{- end }} +{{- if .Values.teams.enabled }} + TEAMS_WEBHOOK_URL: {{ .Values.teams.webhookUrl | b64enc }} +{{- end }} {{- if and .Values.mail.enabled .Values.mail.smtp.pass }} MAIL_SMTP_PASS: {{ .Values.mail.smtp.pass | b64enc }} {{- end }} diff --git a/chart/keel/values.yaml b/chart/keel/values.yaml index 899a5139..0a18c9d1 100644 --- a/chart/keel/values.yaml +++ b/chart/keel/values.yaml @@ -74,10 +74,16 @@ hipchat: userName: "" password: "" +# Mattermost notifications mattermost: enabled: false endpoint: "" +# MS Teams notifications +teams: + enabled: false + webhookUrl: "" + # Mail notifications mail: enabled: false diff --git a/constants/constants.go b/constants/constants.go index d1c5e6b8..6b5af83b 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -31,6 +31,9 @@ const ( EnvMattermostEndpoint = "MATTERMOST_ENDPOINT" EnvMattermostName = "MATTERMOST_USERNAME" + // MS Teams webhook url, see https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#setting-up-a-custom-incoming-webhook + EnvTeamsWebhookUrl = "TEAMS_WEBHOOK_URL" + // Mail notification settings EnvMailTo = "MAIL_TO" EnvMailFrom = "MAIL_FROM" diff --git a/deployment/deployment-template.yaml b/deployment/deployment-template.yaml index 19513b2c..b2942bb9 100644 --- a/deployment/deployment-template.yaml +++ b/deployment/deployment-template.yaml @@ -182,6 +182,9 @@ spec: # Enable mattermost endpoint - name: MATTERMOST_ENDPOINT value: "" + # Enable MS Teams webhook endpoint + - name: TEAMS_WEBHOOK_URL + value: "{{ .teams_webhook_url }}" # Following same pattern as with adjacent examples. However, I can't see why this would work. (There is no top level "slack_channel" in the values.yaml. Nor is there the ".Values" prefix as with deployment.yaml.) Maybe my issue here is that I don't know how this depoyment-template.yaml file is used. I assume deployment.yaml would be the primary one in use. - name: SLACK_TOKEN value: "{{ .slack_token }}" - name: SLACK_CHANNELS diff --git a/extension/notification/teams/manifest.json b/extension/notification/teams/manifest.json new file mode 100644 index 00000000..62f4ea93 --- /dev/null +++ b/extension/notification/teams/manifest.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.7/MicrosoftTeams.schema.json", + "manifestVersion": "0.1", + "id": "e9343a03-0a5e-4c1f-95a8-263a565505a5", // get from microsoft teams? + "version": "1.0", + "packageName": "sh.keel", + "developer": { + "name": "Publisher", + "websiteUrl": "https://keel.sh", + "privacyUrl": "https://www.microsoft.com", + "termsOfUseUrl": "https://www.microsoft.com" + }, + "description": { + "full": "This is a small sample app we made for you! This app has samples of all capabilities Microsoft Teams supports.", + "short": "This is a small sample app we made for you!" + }, + "icons": { + "outline": "sampleapp-outline.png", + "color": "sampleapp-color.png" + }, + "connectors": [ + { + "connectorId": "e9343a03-0a5e-4c1f-95a8-263a565505a5", // get from microsoft teams? + "scopes": [ + "team" + ] + } + ], + "name": { + "short": "Keel", + "full": "Keel" + }, + "accentColor": "#46bd87", + "needsIdentity": "true" + } \ No newline at end of file diff --git a/extension/notification/teams/teams.go b/extension/notification/teams/teams.go new file mode 100644 index 00000000..167238c8 --- /dev/null +++ b/extension/notification/teams/teams.go @@ -0,0 +1,145 @@ +package teams + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "time" + + "github.com/keel-hq/keel/constants" + "github.com/keel-hq/keel/extension/notification" + "github.com/keel-hq/keel/types" + + log "github.com/sirupsen/logrus" +) + +const timeout = 5 * time.Second + +type sender struct { + endpoint string + client *http.Client +} + +// Config represents the configuration of a Teams Webhook Sender. +type Config struct { + Endpoint string +} + +func init() { + notification.RegisterSender("teams", &sender{}) +} + +func (s *sender) Configure(config *notification.Config) (bool, error) { + // Get configuration + var httpConfig Config + + if os.Getenv(constants.EnvTeamsWebhookUrl) != "" { + httpConfig.Endpoint = os.Getenv(constants.EnvTeamsWebhookUrl) + } else { + return false, nil + } + + // Validate endpoint URL. + if httpConfig.Endpoint == "" { + return false, nil + } + if _, err := url.ParseRequestURI(httpConfig.Endpoint); err != nil { + return false, fmt.Errorf("could not parse endpoint URL: %s\n", err) + } + s.endpoint = httpConfig.Endpoint + + // Setup HTTP client. + s.client = &http.Client{ + Transport: http.DefaultTransport, + Timeout: timeout, + } + + log.WithFields(log.Fields{ + "name": "teams", + "webhook": s.endpoint, + }).Info("extension.notification.teams: sender configured") + + return true, nil +} + +type notificationEnvelope struct { + types.EventNotification +} + +type SimpleTeamsMessageCard struct { + _Context string `json:"@context"` + _Type string `json:"@type"` + Sections []TeamsMessageSection `json:"sections"` + Summary string `json:"summary"` + ThemeColor string `json:"themeColor"` +} + +type TeamsMessageSection struct { + ActivityImage string `json:"activityImage"` + ActivitySubtitle string `json:"activitySubtitle"` + ActivityText string `json:"activityText"` + ActivityTitle string `json:"activityTitle"` + Facts []TeamsFact `json:"facts"` + Markdown bool `json:"markdown"` +} + +type TeamsFact struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Microsoft Teams expects the hexidecimal formatted color to not have a "#" at the front +// Source: https://stackoverflow.com/a/48798875/2199949 +func trimFirstChar(s string) string { + for i := range s { + if i > 0 { + // The value i is the index in s of the second + // character. Slice to remove the first character. + return s[i:] + } + } + // There are 0 or 1 characters in the string. + return "" +} + +func (s *sender) Send(event types.EventNotification) error { + // Marshal notification. + jsonNotification, err := json.Marshal(simpleTeamsMessageCard{ + _Type: "MessageCard", + _Context: "http://schema.org/extensions", + ThemeColor: trimFirstChar(event.Level.Color()), + Summary: event.Type.String(), + Sections: []TeamsMessageSection{ + { + ActivityImage: constants.KeelLogoURL, + ActivityText: event.Message, + ActivityTitle: "**" + event.Type.String() + "**" + }, + []TeamsFact{ + { + Name: "Version", + Value: fmt.Sprintf("https://keel.sh %s", version.GetKeelVersion().Version) + } + }, + Markdown: true + } + }) + if err != nil { + return fmt.Errorf("could not marshal: %s", err) + } + + // Send notification via HTTP POST. + resp, err := s.client.Post(s.endpoint, "application/json", bytes.NewBuffer(jsonNotification)) + if err != nil || resp == nil || (resp.StatusCode != 200 && resp.StatusCode != 201) { + if resp != nil { + return fmt.Errorf("got status %d, expected 200/201", resp.StatusCode) + } + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/extension/notification/teams/teams_test.go b/extension/notification/teams/teams_test.go new file mode 100644 index 00000000..ca95ee04 --- /dev/null +++ b/extension/notification/teams/teams_test.go @@ -0,0 +1,70 @@ +package teams + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "fmt" + + "github.com/keel-hq/keel/types" + "github.com/keel-hq/keel/extension/notification/teams" +) + +func TestTrimLeftChar() { + fmt.Printf("%q\n", "Hello, 世界") + fmt.Printf("%q\n", teams.trimLeftChar("")) + fmt.Printf("%q\n", teams.trimLeftChar("H")) + fmt.Printf("%q\n", teams.trimLeftChar("世")) + fmt.Printf("%q\n", teams.trimLeftChar("Hello")) + fmt.Printf("%q\n", teams.trimLeftChar("世界")) +} + +func TestTeamsRequest(t *testing.T) { + currentTime := time.Now() + handler := func(resp http.ResponseWriter, req *http.Request) { + body, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Errorf("failed to parse body: %s", err) + } + + bodyStr := string(body) + + if !strings.Contains(bodyStr, types.NotificationPreDeploymentUpdate.String()) { + t.Errorf("missing deployment type") + } + + if !strings.Contains(bodyStr, "debug") { + t.Errorf("missing level") + } + + if !strings.Contains(bodyStr, "update deployment") { + t.Errorf("missing name") + } + if !strings.Contains(bodyStr, "message here") { + t.Errorf("missing message") + } + + t.Log(bodyStr) + + } + + // create test server with handler + ts := httptest.NewServer(http.HandlerFunc(handler)) + defer ts.Close() + + s := &sender{ + webhook: ts.URL, + client: &http.Client{}, + } + + s.Send(types.EventNotification{ + Name: "update deployment", + Message: "message here", + CreatedAt: currentTime, + Type: types.NotificationPreDeploymentUpdate, + Level: types.LevelDebug, + }) +}