From 8f80fa5bbd65cc35093ab6f10d9b7cb7d1bc58a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20S=C4=99k?= Date: Sun, 18 Aug 2019 15:40:28 +0200 Subject: [PATCH] Refactor notifications package --- internal/notifier/sender.go | 140 ------------------ .../jenkins/notifications}/mailgun.go | 35 +++-- .../jenkins/notifications}/msteams.go | 56 ++++--- .../jenkins/notifications}/msteams_test.go | 44 +++--- .../jenkins/notifications/sender.go | 101 +++++++++++++ .../jenkins/notifications}/slack.go | 38 +++-- .../jenkins/notifications}/slack_test.go | 39 +++-- 7 files changed, 224 insertions(+), 229 deletions(-) delete mode 100644 internal/notifier/sender.go rename {internal/notifier => pkg/controller/jenkins/notifications}/mailgun.go (61%) rename {internal/notifier => pkg/controller/jenkins/notifications}/msteams.go (63%) rename {internal/notifier => pkg/controller/jenkins/notifications}/msteams_test.go (66%) create mode 100644 pkg/controller/jenkins/notifications/sender.go rename {internal/notifier => pkg/controller/jenkins/notifications}/slack.go (73%) rename {internal/notifier => pkg/controller/jenkins/notifications}/slack_test.go (70%) diff --git a/internal/notifier/sender.go b/internal/notifier/sender.go deleted file mode 100644 index b67bc407..00000000 --- a/internal/notifier/sender.go +++ /dev/null @@ -1,140 +0,0 @@ -package notifier - -import ( - "fmt" - "net/http" - - "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - "github.com/jenkinsci/kubernetes-operator/pkg/log" - - "github.com/go-logr/logr" - k8sclient "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - // LogWarn is warning log entry - LogWarn LoggingLevel = "warn" - - // LogInfo is info log entry - LogInfo LoggingLevel = "info" - - titleText = "Operator reconciled." - messageFieldName = "Message" - loggingLevelFieldName = "Logging Level" - crNameFieldName = "CR Name" - configurationTypeFieldName = "Configuration Type" - namespaceFieldName = "Namespace" - footerContent = "Powered by Jenkins Operator <3" -) - -var ( - testConfigurationType = "test-configuration" - testCrName = "test-cr" - testNamespace = "test-namespace" - testMessage = "test-message" - testMessageVerbose = "detail-test-message" - testLoggingLevel = LogWarn - - client = http.Client{} -) - -// StatusColor is useful for better UX -type StatusColor string - -// LoggingLevel is type for selecting different logging levels -type LoggingLevel string - -// Information represents details about operator status -type Information struct { - ConfigurationType string - Namespace string - CrName string - LogLevel LoggingLevel - Message string - MessageVerbose string -} - -// Notification contains message which will be sent -type Notification struct { - Jenkins v1alpha2.Jenkins - K8sClient k8sclient.Client - Logger logr.Logger - Information Information -} - -// Service is skeleton for additional services -type service interface { - Send(i *Notification, config v1alpha2.Notification) error -} - -// Listen is goroutine that listens for incoming messages and sends it -func Listen(notification chan *Notification) { - for n := range notification { - for _, notificationConfig := range n.Jenkins.Spec.Notifications { - var err error - var svc service - - if notificationConfig.Slack != (v1alpha2.Slack{}) { - svc = Slack{} - } else if notificationConfig.Teams != (v1alpha2.Teams{}) { - svc = Teams{} - } else if notificationConfig.Mailgun != (v1alpha2.Mailgun{}) { - svc = Mailgun{} - } else { - n.Logger.V(log.VWarn).Info(fmt.Sprintf("Notification service in `%s` not found or not defined", notificationConfig.Name)) - continue - } - - err = notify(svc, n, notificationConfig) - - if err != nil { - n.Logger.V(log.VWarn).Info(fmt.Sprintf("Failed to send notifications. %+v", err)) - } else { - n.Logger.V(log.VDebug).Info("Sent notification") - } - } - - } -} - -func getStatusColor(logLevel LoggingLevel, svc service) StatusColor { - switch svc.(type) { - case Slack: - switch logLevel { - case LogInfo: - return "#439FE0" - case LogWarn: - return "danger" - default: - return "#c8c8c8" - } - case Teams: - switch logLevel { - case LogInfo: - return "439FE0" - case LogWarn: - return "E81123" - default: - return "C8C8C8" - } - case Mailgun: - switch logLevel { - case LogInfo: - return "blue" - case LogWarn: - return "red" - default: - return "gray" - } - default: - return "#c8c8c8" - } -} - -func notify(svc service, n *Notification, manifest v1alpha2.Notification) error { - if n.Information.LogLevel == LogInfo && string(manifest.LoggingLevel) == string(LogWarn) { - return nil - } - - return svc.Send(n, manifest) -} diff --git a/internal/notifier/mailgun.go b/pkg/controller/jenkins/notifications/mailgun.go similarity index 61% rename from internal/notifier/mailgun.go rename to pkg/controller/jenkins/notifications/mailgun.go index 7f7e5e9f..f96b6fd3 100644 --- a/internal/notifier/mailgun.go +++ b/pkg/controller/jenkins/notifications/mailgun.go @@ -1,16 +1,17 @@ -package notifier +package notifications import ( "context" "fmt" - "github.com/pkg/errors" "time" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" "github.com/mailgun/mailgun-go/v3" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) const content = ` @@ -39,29 +40,41 @@ const content = ` ` -// Mailgun is service for sending emails -type Mailgun struct{} +// MailGun is service for sending emails +type MailGun struct { + k8sClient k8sclient.Client +} + +func (m MailGun) getStatusColor(logLevel LoggingLevel) StatusColor { + switch logLevel { + case LogInfo: + return "blue" + case LogWarn: + return "red" + default: + return "gray" + } +} // Send is function for sending directly to API -func (m Mailgun) Send(n *Notification, config v1alpha2.Notification) error { +func (m MailGun) Send(event Event, config v1alpha2.Notification) error { secret := &corev1.Secret{} - i := n.Information selector := config.Mailgun.APIKeySecretKeySelector - err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret) + err := m.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) if err != nil { - return err + return errors.WithStack(err) } - secretValue := string(secret.Data[selector.Name]) + secretValue := string(secret.Data[selector.Key]) if secretValue == "" { - return errors.Errorf("SecretValue %s is empty", selector.Name) + return errors.Errorf("Mailgun API is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, selector.Name, selector.Key) } mg := mailgun.NewMailgun(config.Mailgun.Domain, secretValue) - htmlMessage := fmt.Sprintf(content, getStatusColor(i.LogLevel, m), i.CrName, i.ConfigurationType, getStatusColor(i.LogLevel, m), string(i.LogLevel)) + htmlMessage := fmt.Sprintf(content, m.getStatusColor(event.LogLevel), event.Jenkins.Name, event.ConfigurationType, m.getStatusColor(event.LogLevel), string(event.LogLevel)) msg := mg.NewMessage(fmt.Sprintf("Jenkins Operator Notifier <%s>", config.Mailgun.From), "Jenkins Operator Status", "", config.Mailgun.Recipient) msg.SetHtml(htmlMessage) diff --git a/internal/notifier/msteams.go b/pkg/controller/jenkins/notifications/msteams.go similarity index 63% rename from internal/notifier/msteams.go rename to pkg/controller/jenkins/notifications/msteams.go index 39ca3ae0..38bab355 100644 --- a/internal/notifier/msteams.go +++ b/pkg/controller/jenkins/notifications/msteams.go @@ -1,20 +1,23 @@ -package notifier +package notifications import ( "bytes" "context" "encoding/json" - "github.com/pkg/errors" "net/http" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) // Teams is Microsoft Teams Service -type Teams struct{} +type Teams struct { + k8sClient k8sclient.Client +} // TeamsMessage is representation of json message structure type TeamsMessage struct { @@ -37,65 +40,74 @@ type TeamsFact struct { Value string `json:"value"` } +func (t Teams) getStatusColor(logLevel LoggingLevel) StatusColor { + switch logLevel { + case LogInfo: + return "439FE0" + case LogWarn: + return "E81123" + default: + return "C8C8C8" + } +} + // Send is function for sending directly to API -func (t Teams) Send(n *Notification, config v1alpha2.Notification) error { +func (t Teams) Send(event Event, config v1alpha2.Notification) error { secret := &corev1.Secret{} - i := n.Information selector := config.Teams.URLSecretKeySelector - err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret) + err := t.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) if err != nil { - return err + return errors.WithStack(err) + } + + secretValue := string(secret.Data[selector.Key]) + if secretValue == "" { + return errors.Errorf("Microsoft Teams webhook URL is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, selector.Name, selector.Key) } msg, err := json.Marshal(TeamsMessage{ Type: "MessageCard", Context: "https://schema.org/extensions", - ThemeColor: getStatusColor(i.LogLevel, t), + ThemeColor: t.getStatusColor(event.LogLevel), Title: titleText, Sections: []TeamsSection{ { Facts: []TeamsFact{ { Name: crNameFieldName, - Value: i.CrName, + Value: event.Jenkins.Name, }, { Name: configurationTypeFieldName, - Value: i.ConfigurationType, + Value: event.ConfigurationType, }, { Name: loggingLevelFieldName, - Value: string(i.LogLevel), + Value: string(event.LogLevel), }, { Name: namespaceFieldName, - Value: i.Namespace, + Value: event.Jenkins.Namespace, }, }, - Text: i.Message, + Text: event.Message, }, }, }) - - secretValue := string(secret.Data[selector.Key]) - if secretValue == "" { - return errors.Errorf("SecretValue %s is empty", selector.Name) - } - if err != nil { - return err + return errors.WithStack(err) } request, err := http.NewRequest("POST", secretValue, bytes.NewBuffer(msg)) if err != nil { - return err + return errors.WithStack(err) } resp, err := client.Do(request) if err != nil { - return err + return errors.WithStack(err) } defer func() { _ = resp.Body.Close() }() diff --git a/internal/notifier/msteams_test.go b/pkg/controller/jenkins/notifications/msteams_test.go similarity index 66% rename from internal/notifier/msteams_test.go rename to pkg/controller/jenkins/notifications/msteams_test.go index bc52715d..8ae40710 100644 --- a/internal/notifier/msteams_test.go +++ b/pkg/controller/jenkins/notifications/msteams_test.go @@ -1,4 +1,4 @@ -package notifier +package notifications import ( "context" @@ -20,61 +20,59 @@ func TestTeams_Send(t *testing.T) { testURLSelectorKeyName := "test-url-selector" testSecretName := "test-secret" - i := Information{ + event := Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: testCrName, + Namespace: testNamespace, + }, + }, ConfigurationType: testConfigurationType, - CrName: testCrName, Message: testMessage, MessageVerbose: testMessageVerbose, - Namespace: testNamespace, LogLevel: testLoggingLevel, } - - notification := &Notification{ - K8sClient: fakeClient, - Information: i, - } + teams := Teams{k8sClient: fakeClient} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var message TeamsMessage decoder := json.NewDecoder(r.Body) err := decoder.Decode(&message) - if err != nil { t.Fatal(err) } assert.Equal(t, message.Title, titleText) - assert.Equal(t, message.ThemeColor, getStatusColor(i.LogLevel, Teams{})) + assert.Equal(t, message.ThemeColor, teams.getStatusColor(event.LogLevel)) mainSection := message.Sections[0] - assert.Equal(t, mainSection.Text, i.Message) + assert.Equal(t, mainSection.Text, event.Message) for _, fact := range mainSection.Facts { switch fact.Name { case configurationTypeFieldName: - assert.Equal(t, fact.Value, i.ConfigurationType) + assert.Equal(t, fact.Value, event.ConfigurationType) case crNameFieldName: - assert.Equal(t, fact.Value, i.CrName) + assert.Equal(t, fact.Value, event.Jenkins.Name) case messageFieldName: - assert.Equal(t, fact.Value, i.Message) + assert.Equal(t, fact.Value, event.Message) case loggingLevelFieldName: - assert.Equal(t, fact.Value, string(i.LogLevel)) + assert.Equal(t, fact.Value, string(event.LogLevel)) case namespaceFieldName: - assert.Equal(t, fact.Value, i.Namespace) + assert.Equal(t, fact.Value, event.Jenkins.Namespace) default: - t.Fail() + t.Errorf("Found unexpected '%+v' fact", fact) } } })) - teams := Teams{} - defer server.Close() secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testSecretName, + Name: testSecretName, + Namespace: testNamespace, }, Data: map[string][]byte{ @@ -82,10 +80,10 @@ func TestTeams_Send(t *testing.T) { }, } - err := notification.K8sClient.Create(context.TODO(), secret) + err := fakeClient.Create(context.TODO(), secret) assert.NoError(t, err) - err = teams.Send(notification, v1alpha2.Notification{ + err = teams.Send(event, v1alpha2.Notification{ Teams: v1alpha2.Teams{ URLSecretKeySelector: v1alpha2.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ diff --git a/pkg/controller/jenkins/notifications/sender.go b/pkg/controller/jenkins/notifications/sender.go new file mode 100644 index 00000000..50b905b4 --- /dev/null +++ b/pkg/controller/jenkins/notifications/sender.go @@ -0,0 +1,101 @@ +package notifications + +import ( + "fmt" + "github.com/pkg/errors" + "net/http" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/log" + + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // LogWarn is warning log entry + LogWarn LoggingLevel = "warn" + + // LogInfo is info log entry + LogInfo LoggingLevel = "info" + + titleText = "Operator reconciled." + messageFieldName = "Message" + loggingLevelFieldName = "Logging Level" + crNameFieldName = "CR Name" + configurationTypeFieldName = "Configuration Type" + namespaceFieldName = "Namespace" + footerContent = "Powered by Jenkins Operator" +) + +var ( + testConfigurationType = "test-configuration" + testCrName = "test-cr" + testNamespace = "default" + testMessage = "test-message" + testMessageVerbose = "detail-test-message" + testLoggingLevel = LogWarn + + client = http.Client{} +) + +// StatusColor is useful for better UX +type StatusColor string + +// LoggingLevel is type for selecting different logging levels +type LoggingLevel string + +// Event contains event details which will be sent as a notification +type Event struct { + Jenkins v1alpha2.Jenkins + ConfigurationType string + LogLevel LoggingLevel + Message string + MessageVerbose string +} + +type service interface { + Send(event Event, notificationConfig v1alpha2.Notification) error +} + +// Listen listens for incoming events and send it as notifications +func Listen(events chan Event, k8sClient k8sclient.Client) { + for event := range events { + logger := log.Log.WithValues("cr", event.Jenkins.Name) + for _, notificationConfig := range event.Jenkins.Spec.Notifications { + var err error + var svc service + + if notificationConfig.Slack != (v1alpha2.Slack{}) { + svc = Slack{k8sClient: k8sClient} + } else if notificationConfig.Teams != (v1alpha2.Teams{}) { + svc = Teams{k8sClient: k8sClient} + } else if notificationConfig.Mailgun != (v1alpha2.Mailgun{}) { + svc = MailGun{k8sClient: k8sClient} + } else { + logger.V(log.VWarn).Info(fmt.Sprintf("Unexpected notification `%+v`", notificationConfig)) + continue + } + + go func(notificationConfig v1alpha2.Notification) { + err = notify(svc, event, notificationConfig) + + if err != nil { + if log.Debug { + logger.Error(nil, fmt.Sprintf("%+v", errors.WithMessage(err, "failed to send notification"))) + } else { + logger.Error(nil, fmt.Sprintf("%s", errors.WithMessage(err, "failed to send notification"))) + } + } + }(notificationConfig) + } + + } +} + +func notify(svc service, event Event, manifest v1alpha2.Notification) error { + if event.LogLevel == LogInfo && string(manifest.LoggingLevel) == string(LogWarn) { + return nil + } + + return svc.Send(event, manifest) +} diff --git a/internal/notifier/slack.go b/pkg/controller/jenkins/notifications/slack.go similarity index 73% rename from internal/notifier/slack.go rename to pkg/controller/jenkins/notifications/slack.go index c8c3ba94..733d221e 100644 --- a/internal/notifier/slack.go +++ b/pkg/controller/jenkins/notifications/slack.go @@ -1,4 +1,4 @@ -package notifier +package notifications import ( "bytes" @@ -11,10 +11,13 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) // Slack is messaging service -type Slack struct{} +type Slack struct { + k8sClient k8sclient.Client +} // SlackMessage is representation of json message type SlackMessage struct { @@ -40,14 +43,23 @@ type SlackField struct { Short bool `json:"short"` } -// Send is function for sending directly to API -func (s Slack) Send(n *Notification, config v1alpha2.Notification) error { - secret := &corev1.Secret{} - i := n.Information +func (s Slack) getStatusColor(logLevel LoggingLevel) StatusColor { + switch logLevel { + case LogInfo: + return "#439FE0" + case LogWarn: + return "danger" + default: + return "#c8c8c8" + } +} +// Send is function for sending directly to API +func (s Slack) Send(event Event, config v1alpha2.Notification) error { + secret := &corev1.Secret{} selector := config.Slack.URLSecretKeySelector - err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret) + err := s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) if err != nil { return err } @@ -56,32 +68,32 @@ func (s Slack) Send(n *Notification, config v1alpha2.Notification) error { Attachments: []SlackAttachment{ { Fallback: "", - Color: getStatusColor(i.LogLevel, s), + Color: s.getStatusColor(event.LogLevel), Text: titleText, Fields: []SlackField{ { Title: messageFieldName, - Value: i.Message, + Value: event.Message, Short: false, }, { Title: crNameFieldName, - Value: i.CrName, + Value: event.Jenkins.Name, Short: true, }, { Title: configurationTypeFieldName, - Value: i.ConfigurationType, + Value: event.ConfigurationType, Short: true, }, { Title: loggingLevelFieldName, - Value: string(i.LogLevel), + Value: string(event.LogLevel), Short: true, }, { Title: namespaceFieldName, - Value: i.Namespace, + Value: event.Jenkins.Namespace, Short: true, }, }, diff --git a/internal/notifier/slack_test.go b/pkg/controller/jenkins/notifications/slack_test.go similarity index 70% rename from internal/notifier/slack_test.go rename to pkg/controller/jenkins/notifications/slack_test.go index cb5f3221..30b8791b 100644 --- a/internal/notifier/slack_test.go +++ b/pkg/controller/jenkins/notifications/slack_test.go @@ -1,4 +1,4 @@ -package notifier +package notifications import ( "context" @@ -20,19 +20,19 @@ func TestSlack_Send(t *testing.T) { testURLSelectorKeyName := "test-url-selector" testSecretName := "test-secret" - i := Information{ + event := Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: testCrName, + Namespace: testNamespace, + }, + }, ConfigurationType: testConfigurationType, - CrName: testCrName, Message: testMessage, MessageVerbose: testMessageVerbose, - Namespace: testNamespace, LogLevel: testLoggingLevel, } - - notification := &Notification{ - K8sClient: fakeClient, - Information: i, - } + slack := Slack{k8sClient: fakeClient} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var message SlackMessage @@ -49,29 +49,30 @@ func TestSlack_Send(t *testing.T) { for _, field := range mainAttachment.Fields { switch field.Title { case configurationTypeFieldName: - assert.Equal(t, field.Value, i.ConfigurationType) + assert.Equal(t, field.Value, event.ConfigurationType) case crNameFieldName: - assert.Equal(t, field.Value, i.CrName) + assert.Equal(t, field.Value, event.Jenkins.Name) case messageFieldName: - assert.Equal(t, field.Value, i.Message) + assert.Equal(t, field.Value, event.Message) case loggingLevelFieldName: - assert.Equal(t, field.Value, string(i.LogLevel)) + assert.Equal(t, field.Value, string(event.LogLevel)) case namespaceFieldName: - assert.Equal(t, field.Value, i.Namespace) + assert.Equal(t, field.Value, event.Jenkins.Namespace) default: t.Fail() } } assert.Equal(t, mainAttachment.Footer, footerContent) - assert.Equal(t, mainAttachment.Color, getStatusColor(i.LogLevel, Slack{})) + assert.Equal(t, mainAttachment.Color, slack.getStatusColor(event.LogLevel)) })) defer server.Close() secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testSecretName, + Name: testSecretName, + Namespace: testNamespace, }, Data: map[string][]byte{ @@ -79,12 +80,10 @@ func TestSlack_Send(t *testing.T) { }, } - err := notification.K8sClient.Create(context.TODO(), secret) + err := fakeClient.Create(context.TODO(), secret) assert.NoError(t, err) - slack := Slack{} - - err = slack.Send(notification, v1alpha2.Notification{ + err = slack.Send(event, v1alpha2.Notification{ Slack: v1alpha2.Slack{ URLSecretKeySelector: v1alpha2.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{