Repository: fluxcd/notification-controller
Pull request #149 (2021-02-27 17:28:20)

Provide the ability to send events to Google Chat
By essentially replicating the implementations for Teams and Slack a specific implementation for Google Chat has been developed and tested.

The are two points of note:

1. I've had to filter out 'Progressing' events as these seemed to replace the previous message within Google Chat. I couldn't easily find out why. However the output in chat is very similar to Discord so why this system does not display these messages is a mystery?

2. I've included the event timestamp as 'meta-data' within the message. Other implementation have not done this, so I'm happy to remove it for consistency sake if that is preferred.


Finally. I am not a Go developer, this is actually my first time coding in it. I like it, it's very clean. But I cut my teeth in C, C++, Java and beyond. So my apologies for any novice mistakes.


Patch:
diff --git a/api/v1beta1/provider_types.go b/api/v1beta1/provider_types.go
index 9ceb0c9..63ac893 100644
--- a/api/v1beta1/provider_types.go
+++ b/api/v1beta1/provider_types.go
@@ -28,7 +28,7 @@ const (
 // ProviderSpec defines the desired state of Provider
 type ProviderSpec struct {
 	// Type of provider
-	// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;github;gitlab;bitbucket;azuredevops
+	// +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;github;gitlab;bitbucket;azuredevops;googlechat
 	// +required
 	Type string `json:"type"`
 
@@ -68,6 +68,7 @@ const (
 	GitLabProvider      string = "gitlab"
 	BitbucketProvider   string = "bitbucket"
 	AzureDevOpsProvider string = "azuredevops"
+	GoogleChatProvider  string = "googlechat"
 )
 
 // ProviderStatus defines the observed state of Provider
diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
index 2fed749..511361f 100644
--- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
+++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
@@ -79,6 +79,7 @@ spec:
                 - gitlab
                 - bitbucket
                 - azuredevops
+                - googlechat
                 type: string
               username:
                 description: Bot username for this provider
diff --git a/docs/spec/v1beta1/provider.md b/docs/spec/v1beta1/provider.md
index a5243e6..7fb89e6 100644
--- a/docs/spec/v1beta1/provider.md
+++ b/docs/spec/v1beta1/provider.md
@@ -43,6 +43,7 @@ Notification providers:
 * Discord
 * Microsoft Teams
 * Rocket
+* Google Chat
 * Generic webhook
 
 Git commit status providers:
@@ -103,7 +104,7 @@ kubectl create secret generic webhook-url \
 
 Note that the secret must contain an `address` field.
 
-The provider type can be: `slack`, `msteams`, `rocket`, `discord`, `github` or `generic`.
+The provider type can be: `slack`, `msteams`, `rocket`, `discord`, `googlechat`, `github`, `gitlab`, or `generic`.
 
 When type `generic` is specified, the notification controller will post the
 incoming [event](event.md) in JSON format to the webhook address.
diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go
index a711089..3fdd7c6 100644
--- a/internal/notifier/factory.go
+++ b/internal/notifier/factory.go
@@ -66,6 +66,8 @@ func (f Factory) Notifier(provider string) (Interface, error) {
 		n, err = NewBitbucket(f.URL, f.Token)
 	case v1beta1.AzureDevOpsProvider:
 		n, err = NewAzureDevOps(f.URL, f.Token)
+	case v1beta1.GoogleChatProvider:
+		n, err = NewGoogleChat(f.URL, f.ProxyURL)
 	default:
 		err = fmt.Errorf("provider %s not supported", provider)
 	}
diff --git a/internal/notifier/google_chat.go b/internal/notifier/google_chat.go
new file mode 100644
index 0000000..cc86903
--- /dev/null
+++ b/internal/notifier/google_chat.go
@@ -0,0 +1,156 @@
+/*
+Copyright 2020 The Flux authors
+
+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 notifier
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/fluxcd/pkg/recorder"
+)
+
+// Slack holds the hook URL
+type GoogleChat struct {
+	URL      string
+	ProxyURL string
+	Username string
+	Channel  string
+}
+
+// GoogleChatPayload holds the channel and attachments
+type GoogleChatPayload struct {
+	Cards []GoogleChatCard `json:"cards"`
+}
+
+type GoogleChatCard struct {
+	Header   GoogleChatCardHeader    `json:"header"`
+	Sections []GoogleChatCardSection `json:"sections"`
+}
+
+type GoogleChatCardHeader struct {
+	Title      string  `json:"title"`
+	SubTitle   string  `json:"subtitle"`
+	ImageUrl   *string `json:"imageUrl"`
+	ImageStyle *string `json:"imageStyle"`
+}
+
+type GoogleChatCardSection struct {
+	Header  string                 `json:"header"`
+	Widgets []GoogleChatCardWidget `json:"widgets"`
+}
+
+type GoogleChatCardWidget struct {
+	TextParagraph *GoogleChatCardWidgetTextParagraph `json:"textParagraph"`
+	KeyValue      *GoogleChatCardWidgetKeyValue      `json:"keyValue"`
+}
+
+type GoogleChatCardWidgetTextParagraph struct {
+	Text string `json:"text"`
+}
+
+type GoogleChatCardWidgetKeyValue struct {
+	TopLabel         string  `json:"topLabel"`
+	Content          string  `json:"content"`
+	ContentMultiLine bool    `json:"contentMultiline"`
+	BottomLabel      *string `json:"bottomLabel"`
+	Icon             *string `json:"icon"`
+}
+
+// NewGoogleChat validates the Google Chat URL and returns a GoogleChat object
+func NewGoogleChat(hookURL string, proxyURL string) (*GoogleChat, error) {
+	_, err := url.ParseRequestURI(hookURL)
+	if err != nil {
+		return nil, fmt.Errorf("invalid Google Chat hook URL %s", hookURL)
+	}
+
+	return &GoogleChat{
+		URL:      hookURL,
+		ProxyURL: proxyURL,
+	}, nil
+}
+
+// Post Google Chat message
+func (s *GoogleChat) Post(event recorder.Event) error {
+	// Skip any update events
+	if isCommitStatus(event.Metadata, "update") {
+		return nil
+	}
+
+	// Skip progressing events. For some reason, these are coming through and then Google Chat seems to replace
+	// the more meaningful message with these which is unhelpful!
+	if event.Reason == "Progressing" {
+		return nil
+	}
+
+	// Header
+	objName := fmt.Sprintf("%s/%s.%s", strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace)
+	header := GoogleChatCardHeader{
+		Title:    objName,
+		SubTitle: event.ReportingController,
+	}
+
+	sections := make([]GoogleChatCardSection, 0)
+
+	// Message
+	messageText := event.Message
+	if event.Severity == recorder.EventSeverityError {
+		messageText = fmt.Sprintf("<font color=\"#ff0000\">%s</font>", event.Message)
+	}
+	sections = append(sections, GoogleChatCardSection{
+		Widgets: []GoogleChatCardWidget{
+			{
+				TextParagraph: &GoogleChatCardWidgetTextParagraph{
+					Text: messageText,
+				},
+			},
+		},
+	})
+
+	// Meta-Data
+	if len(event.Metadata) > 0 {
+		kvfields := make([]GoogleChatCardWidget, 0, len(event.Metadata))
+		for k, v := range event.Metadata {
+			kvfields = append(kvfields, GoogleChatCardWidget{
+				KeyValue: &GoogleChatCardWidgetKeyValue{
+					TopLabel:         k,
+					Content:          v,
+					ContentMultiLine: false,
+				},
+			})
+		}
+		sections = append(sections, GoogleChatCardSection{
+			Widgets: kvfields,
+		})
+	}
+
+	card := GoogleChatCard{
+		Header:   header,
+		Sections: sections,
+	}
+
+	payload := GoogleChatPayload{
+		Cards: []GoogleChatCard{card},
+	}
+
+	err := postMessage(s.URL, s.ProxyURL, payload)
+	if err != nil {
+		return fmt.Errorf("postMessage failed: %w", err)
+	}
+
+	return nil
+}


Test patch:
diff --git a/internal/notifier/google_chat_test.go b/internal/notifier/google_chat_test.go
new file mode 100644
index 0000000..f7734c2
--- /dev/null
+++ b/internal/notifier/google_chat_test.go
@@ -0,0 +1,50 @@
+/*
+Copyright 2020 The Flux authors
+
+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 notifier
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestGoogleChat_Post(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		b, err := ioutil.ReadAll(r.Body)
+		require.NoError(t, err)
+		var payload = GoogleChatPayload{}
+		err = json.Unmarshal(b, &payload)
+		require.NoError(t, err)
+
+		require.Equal(t, "gitrepository/webapp.gitops-system", payload.Cards[0].Header.Title)
+		require.Equal(t, "source-controller", payload.Cards[0].Header.SubTitle)
+		require.Equal(t, "message", payload.Cards[0].Sections[0].Widgets[0].TextParagraph.Text)
+		require.Equal(t, "test", payload.Cards[0].Sections[1].Widgets[0].KeyValue.TopLabel)
+		require.Equal(t, "metadata", payload.Cards[0].Sections[1].Widgets[0].KeyValue.Content)
+	}))
+	defer ts.Close()
+
+	google_chat, err := NewGoogleChat(ts.URL, "")
+	require.NoError(t, err)
+
+	err = google_chat.Post(testEvent())
+	require.NoError(t, err)
+}