Improve notification mechanism

This commit is contained in:
Jakub Al-Khalili 2019-08-01 11:16:17 +02:00
parent 61d5311ac2
commit 75f95be65a
6 changed files with 214 additions and 186 deletions

View File

@ -3,8 +3,14 @@ package notifier
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/mailgun/mailgun-go/v3"
"time" "time"
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
"github.com/jenkinsci/kubernetes-operator/pkg/log"
"github.com/mailgun/mailgun-go/v3"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
) )
// Mailgun is service for sending emails // Mailgun is service for sending emails
@ -15,8 +21,19 @@ type Mailgun struct {
} }
// Send is function for sending directly to API // Send is function for sending directly to API
func (m Mailgun) Send(secret string, i *Information) error { func (m Mailgun) Send(n *Notification) error {
mg := mailgun.NewMailgun(m.Domain, secret) var selector v1alpha2.SecretKeySelector
secret := &corev1.Secret{}
i := n.Information
err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret)
if err != nil {
n.Logger.V(log.VWarn).Info(fmt.Sprintf("Failed to get secret with name `%s`. %+v", selector.Name, err))
return err
}
mg := mailgun.NewMailgun(m.Domain, secret.StringData[selector.Name])
content := ` content := `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
@ -45,14 +62,14 @@ func (m Mailgun) Send(secret string, i *Information) error {
</html> </html>
` `
content = fmt.Sprintf(content, getStatusColor(i.Status, m), i.CrName, i.ConfigurationType, getStatusColor(i.Status, m), getStatusName(i.Status)) content = fmt.Sprintf(content, getStatusColor(i.LogLevel, m), i.CrName, i.ConfigurationType, getStatusColor(i.LogLevel, m), string(i.LogLevel))
msg := mg.NewMessage(fmt.Sprintf("Jenkins Operator Notifier <%s>", m.From), "Jenkins Operator Status", "", m.Recipient) msg := mg.NewMessage(fmt.Sprintf("Jenkins Operator Notifier <%s>", m.From), "Jenkins Operator Status", "", m.Recipient)
msg.SetHtml(content) msg.SetHtml(content)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel() defer cancel()
_, _, err := mg.Send(ctx, msg) _, _, err = mg.Send(ctx, msg)
if err != nil { if err != nil {
return err return err

View File

@ -2,12 +2,22 @@ package notifier
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
"github.com/jenkinsci/kubernetes-operator/pkg/log"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
) )
// Teams is Microsoft Teams Service // Teams is Microsoft Teams Service
type Teams struct{} type Teams struct {
apiURL string
}
// TeamsMessage is representation of json message structure // TeamsMessage is representation of json message structure
type TeamsMessage struct { type TeamsMessage struct {
@ -31,20 +41,16 @@ type TeamsFact struct {
} }
// Send is function for sending directly to API // Send is function for sending directly to API
func (t Teams) Send(secret string, i *Information) error { func (t Teams) Send(n *Notification) error {
err := i.Error var selector v1alpha2.SecretKeySelector
var errMessage string secret := &corev1.Secret{}
if err != nil { i := n.Information
errMessage = err.Error()
} else {
errMessage = noErrorMessage
}
msg, err := json.Marshal(TeamsMessage{ msg, err := json.Marshal(TeamsMessage{
Type: "MessageCard", Type: "MessageCard",
Context: "https://schema.org/extensions", Context: "https://schema.org/extensions",
ThemeColor: getStatusColor(i.Status, t), ThemeColor: getStatusColor(i.LogLevel, t),
Title: titleText, Title: titleText,
Sections: []TeamsSection{ Sections: []TeamsSection{
{ {
@ -58,20 +64,33 @@ func (t Teams) Send(secret string, i *Information) error {
Value: i.ConfigurationType, Value: i.ConfigurationType,
}, },
{ {
Name: statusFieldName, Name: loggingLevelFieldName,
Value: getStatusName(i.Status), Value: string(i.LogLevel),
},
{
Name: namespaceFieldName,
Value: i.Namespace,
}, },
}, },
Text: errMessage, Text: i.Message,
}, },
}, },
}) })
if t.apiURL == "" {
err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret)
if err != nil {
n.Logger.V(log.VWarn).Info(fmt.Sprintf("Failed to get secret with name `%s`. %+v", selector.Name, err))
}
t.apiURL = secret.StringData[selector.Name]
}
if err != nil { if err != nil {
return err return err
} }
request, err := http.NewRequest("POST", secret, bytes.NewBuffer(msg)) request, err := http.NewRequest("POST", t.apiURL, bytes.NewBuffer(msg))
if err != nil { if err != nil {
return err return err
} }
@ -81,10 +100,7 @@ func (t Teams) Send(secret string, i *Information) error {
return err return err
} }
err = resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if err != nil {
return err
}
return nil return nil
} }

View File

@ -2,20 +2,25 @@ package notifier
import ( import (
"encoding/json" "encoding/json"
"github.com/stretchr/testify/assert"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestTeams_Send(t *testing.T) { func TestTeams_Send(t *testing.T) {
teams := Teams{}
i := &Information{ i := &Information{
ConfigurationType: testConfigurationType, ConfigurationType: testConfigurationType,
CrName: testCrName, CrName: testCrName,
Status: testStatus, Message: testMessage,
Error: testError, MessageVerbose: testMessageVerbose,
Namespace: testNamespace,
LogLevel: testLoggingLevel,
}
notification := &Notification{
Information: i,
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -28,32 +33,28 @@ func TestTeams_Send(t *testing.T) {
} }
assert.Equal(t, message.Title, titleText) assert.Equal(t, message.Title, titleText)
assert.Equal(t, message.ThemeColor, getStatusColor(i.Status, teams)) assert.Equal(t, message.ThemeColor, getStatusColor(i.LogLevel, Teams{}))
mainSection := message.Sections[0] mainSection := message.Sections[0]
assert.Equal(t, mainSection.Text, noErrorMessage) assert.Equal(t, mainSection.Text, i.Message)
for _, fact := range mainSection.Facts { for _, fact := range mainSection.Facts {
switch fact.Name { switch fact.Name {
case configurationTypeFieldName: case configurationTypeFieldName:
if fact.Value != i.ConfigurationType { assert.Equal(t, fact.Value, i.ConfigurationType)
t.Fatalf("%s is not equal! Must be %s", configurationTypeFieldName, i.ConfigurationType)
}
case crNameFieldName: case crNameFieldName:
if fact.Value != i.CrName { assert.Equal(t, fact.Value, i.CrName)
t.Fatalf("%s is not equal! Must be %s", crNameFieldName, i.CrName) case messageFieldName:
} assert.Equal(t, fact.Value, i.Message)
case statusFieldName: case loggingLevelFieldName:
if fact.Value != getStatusName(i.Status) { assert.Equal(t, fact.Value, string(i.LogLevel))
t.Fatalf("%s is not equal! Must be %s", statusFieldName, getStatusName(i.Status))
}
} }
} }
})) }))
teams := Teams{apiURL: server.URL}
defer server.Close() defer server.Close()
if err := teams.Send(server.URL, i); err != nil { assert.NoError(t, teams.Send(notification))
t.Fatal(err)
}
} }

View File

@ -1,160 +1,132 @@
package notifier package notifier
import ( import (
"context"
"fmt" "fmt"
"net/http"
"github.com/go-logr/logr" "github.com/go-logr/logr"
"net/http"
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
"github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/jenkinsci/kubernetes-operator/pkg/log"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
k8sclient "sigs.k8s.io/controller-runtime/pkg/client" 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 ( var (
testConfigurationType = "test-configuration" testConfigurationType = "test-configuration"
testCrName = "test-cr" testCrName = "test-cr"
testStatus Status = 1 testNamespace = "test-namespace"
testError error testMessage = "test-message"
testMessageVerbose = "detail-test-message"
testLoggingLevel = LogWarn
client = http.Client{} client = http.Client{}
) )
const (
// StatusSuccess contains value for success state
StatusSuccess = 0
// StatusError contains value for error state
StatusError = 1
noErrorMessage = "No errors has found."
titleText = "Operator reconciled."
statusMessageFieldName = "Status message"
statusFieldName = "Status"
crNameFieldName = "CR Name"
configurationTypeFieldName = "Configuration Type"
footerContent = "Powered by Jenkins Operator <3"
)
// Status represents the state of operator
type Status int
// StatusColor is useful for better UX // StatusColor is useful for better UX
type StatusColor string type StatusColor string
// LoggingLevel is type for selecting different logging levels
type LoggingLevel string
// Information represents details about operator status // Information represents details about operator status
type Information struct { type Information struct {
ConfigurationType string ConfigurationType string
Namespace string
CrName string CrName string
Status Status LogLevel LoggingLevel
Error error Message string
MessageVerbose string
} }
// Notification contains message which will be sent // Notification contains message which will be sent
type Notification struct { type Notification struct {
Jenkins *v1alpha2.Jenkins Jenkins *v1alpha2.Jenkins
K8sClient k8sclient.Client K8sClient k8sclient.Client
Logger logr.Logger Logger logr.Logger
// Recipient is mobile number or email address
// It's not used in Slack or Microsoft Teams
Recipient string
Information *Information Information *Information
} }
// Service is skeleton for additional services // Service is skeleton for additional services
type Service interface { type service interface {
Send(secret string, i *Information) error Send(i *Notification) error
} }
// Listen is goroutine that listens for incoming messages and sends it // Listen is goroutine that listens for incoming messages and sends it
func Listen(notification chan *Notification) { func Listen(notification chan *Notification) {
n := <-notification <-notification
if len(n.Jenkins.Spec.Notification) > 0 { for n := range notification {
for _, endpoint := range n.Jenkins.Spec.Notification { if len(n.Jenkins.Spec.Notification) > 0 {
var err error for _, endpoint := range n.Jenkins.Spec.Notification {
var service Service var err error
var selector v1alpha2.SecretKeySelector var svc service
secret := &corev1.Secret{}
if endpoint.Slack != (v1alpha2.Slack{}) { if endpoint.Slack != (v1alpha2.Slack{}) {
n.Logger.V(log.VDebug).Info("Slack detected") svc = Slack{}
service = Slack{} } else if endpoint.Teams != (v1alpha2.Teams{}) {
selector = endpoint.Slack.URLSecretKeySelector svc = Teams{}
} else if endpoint.Teams != (v1alpha2.Teams{}) { } else if endpoint.Mailgun != (v1alpha2.Mailgun{}) {
n.Logger.V(log.VDebug).Info("Microsoft Teams detected") svc = Mailgun{
service = Teams{} Domain: endpoint.Mailgun.Domain,
selector = endpoint.Teams.URLSecretKeySelector Recipient: endpoint.Mailgun.Recipient,
} else if endpoint.Mailgun != (v1alpha2.Mailgun{}) { From: endpoint.Mailgun.From,
n.Logger.V(log.VDebug).Info("Mailgun detected") }
service = Mailgun{ } else {
Domain: endpoint.Mailgun.Domain, n.Logger.V(log.VWarn).Info("Notification service not found or not defined")
Recipient: endpoint.Mailgun.Recipient,
From: endpoint.Mailgun.From,
} }
selector = endpoint.Mailgun.APIKeySecretKeySelector
} else {
n.Logger.Info("Notification service not found or not defined")
}
err = n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret) err = notify(svc, n)
if err != nil {
n.Logger.Info(fmt.Sprintf("Failed to get secret with name `%s`. %+v", selector.Name, err))
}
n.Logger.V(log.VDebug).Info(fmt.Sprintf("Endpoint URL: %s", string(secret.Data[selector.Key]))) if err != nil {
err = notify(service, string(secret.Data[selector.Key]), n.Information) n.Logger.V(log.VWarn).Info(fmt.Sprintf("Failed to send notifications. %+v", err))
} else {
if err != nil { n.Logger.V(log.VDebug).Info("Sent notification")
n.Logger.Info(fmt.Sprintf("Failed to send notifications. %+v", err)) }
} else {
n.Logger.Info("Sent notification")
} }
} }
} }
} }
func getStatusName(status Status) string { func getStatusColor(logLevel LoggingLevel, svc service) StatusColor {
switch status { switch svc.(type) {
case StatusSuccess:
return "Success"
case StatusError:
return "Error"
default:
return "Undefined"
}
}
func getStatusColor(status Status, service Service) StatusColor {
switch service.(type) {
case Slack: case Slack:
switch status { switch logLevel {
case StatusSuccess: case LogInfo:
return "good" return "#439FE0"
case StatusError: case LogWarn:
return "danger" return "danger"
default: default:
return "#c8c8c8" return "#c8c8c8"
} }
case Teams: case Teams:
switch status { switch logLevel {
case StatusSuccess: case LogInfo:
return "54A254" return "439FE0"
case StatusError: case LogWarn:
return "E81123" return "E81123"
default: default:
return "C8C8C8" return "C8C8C8"
} }
case Mailgun: case Mailgun:
switch status { switch logLevel {
case StatusSuccess: case LogInfo:
return "green" return "blue"
case StatusError: case LogWarn:
return "red" return "red"
default: default:
return "gray" return "gray"
@ -164,15 +136,15 @@ func getStatusColor(status Status, service Service) StatusColor {
} }
} }
func notify(service Service, secret string, i *Information) error { func notify(svc service, n *Notification) error {
var err error var err error
switch svc := service.(type) { switch s := svc.(type) {
case Slack: case Slack:
err = svc.Send(secret, i) err = s.Send(n)
case Teams: case Teams:
err = svc.Send(secret, i) err = s.Send(n)
case Mailgun: case Mailgun:
err = svc.Send(secret, i) err = s.Send(n)
} }
return err return err

View File

@ -2,12 +2,22 @@ package notifier
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
"github.com/jenkinsci/kubernetes-operator/pkg/log"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
) )
// Slack is messaging service // Slack is messaging service
type Slack struct{} type Slack struct {
apiURL string
}
// SlackMessage is representation of json message // SlackMessage is representation of json message
type SlackMessage struct { type SlackMessage struct {
@ -34,26 +44,31 @@ type SlackField struct {
} }
// Send is function for sending directly to API // Send is function for sending directly to API
func (s Slack) Send(secret string, i *Information) error { func (s Slack) Send(n *Notification) error {
err := i.Error var selector v1alpha2.SecretKeySelector
var errMessage string secret := &corev1.Secret{}
if err != nil { i := n.Information
errMessage = err.Error()
} else { if s.apiURL == "" {
errMessage = noErrorMessage err := n.K8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: n.Jenkins.Namespace}, secret)
if err != nil {
n.Logger.V(log.VWarn).Info(fmt.Sprintf("Failed to get secret with name `%s`. %+v", selector.Name, err))
}
s.apiURL = secret.StringData[selector.Name]
} }
slackMessage, err := json.Marshal(SlackMessage{ slackMessage, err := json.Marshal(SlackMessage{
Attachments: []SlackAttachment{ Attachments: []SlackAttachment{
{ {
Fallback: "", Fallback: "",
Color: getStatusColor(i.Status, s), Color: getStatusColor(i.LogLevel, s),
Text: titleText, Text: titleText,
Fields: []SlackField{ Fields: []SlackField{
{ {
Title: statusMessageFieldName, Title: messageFieldName,
Value: errMessage, Value: i.Message,
Short: false, Short: false,
}, },
{ {
@ -66,6 +81,16 @@ func (s Slack) Send(secret string, i *Information) error {
Value: i.ConfigurationType, Value: i.ConfigurationType,
Short: true, Short: true,
}, },
{
Title: loggingLevelFieldName,
Value: string(i.LogLevel),
Short: true,
},
{
Title: namespaceFieldName,
Value: i.Namespace,
Short: true,
},
}, },
Footer: footerContent, Footer: footerContent,
}, },
@ -76,7 +101,7 @@ func (s Slack) Send(secret string, i *Information) error {
return err return err
} }
request, err := http.NewRequest("POST", secret, bytes.NewBuffer(slackMessage)) request, err := http.NewRequest("POST", s.apiURL, bytes.NewBuffer(slackMessage))
if err != nil { if err != nil {
return err return err
} }
@ -86,10 +111,6 @@ func (s Slack) Send(secret string, i *Information) error {
return err return err
} }
err = resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if err != nil {
return err
}
return nil return nil
} }

View File

@ -2,20 +2,25 @@ package notifier
import ( import (
"encoding/json" "encoding/json"
"github.com/stretchr/testify/assert"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestSlack_Send(t *testing.T) { func TestSlack_Send(t *testing.T) {
slack := Slack{}
i := &Information{ i := &Information{
ConfigurationType: testConfigurationType, ConfigurationType: testConfigurationType,
CrName: testCrName, CrName: testCrName,
Status: testStatus, Message: testMessage,
Error: testError, MessageVerbose: testMessageVerbose,
Namespace: testNamespace,
LogLevel: testLoggingLevel,
}
notification := &Notification{
Information: i,
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -34,27 +39,23 @@ func TestSlack_Send(t *testing.T) {
for _, field := range mainAttachment.Fields { for _, field := range mainAttachment.Fields {
switch field.Title { switch field.Title {
case configurationTypeFieldName: case configurationTypeFieldName:
if field.Value != i.ConfigurationType { assert.Equal(t, field.Value, i.ConfigurationType)
t.Fatalf("%s is not equal! Must be %s", configurationTypeFieldName, i.ConfigurationType)
}
case crNameFieldName: case crNameFieldName:
if field.Value != i.CrName { assert.Equal(t, field.Value, i.CrName)
t.Fatalf("%s is not equal! Must be %s", crNameFieldName, i.CrName) case messageFieldName:
} assert.Equal(t, field.Value, i.Message)
case statusMessageFieldName: case loggingLevelFieldName:
if field.Value != noErrorMessage { assert.Equal(t, field.Value, string(i.LogLevel))
t.Fatalf("Error thrown but not expected")
}
} }
} }
assert.Equal(t, mainAttachment.Footer, footerContent) assert.Equal(t, mainAttachment.Footer, footerContent)
assert.Equal(t, mainAttachment.Color, getStatusColor(i.Status, slack)) assert.Equal(t, mainAttachment.Color, getStatusColor(i.LogLevel, Slack{}))
})) }))
defer server.Close() defer server.Close()
if err := slack.Send(server.URL, i); err != nil { slack := Slack{apiURL: server.URL}
t.Fatal(err)
} assert.NoError(t, slack.Send(notification))
} }