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 (
"context"
"fmt"
"github.com/mailgun/mailgun-go/v3"
"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
@ -15,8 +21,19 @@ type Mailgun struct {
}
// Send is function for sending directly to API
func (m Mailgun) Send(secret string, i *Information) error {
mg := mailgun.NewMailgun(m.Domain, secret)
func (m Mailgun) Send(n *Notification) error {
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 := `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
@ -45,14 +62,14 @@ func (m Mailgun) Send(secret string, i *Information) error {
</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.SetHtml(content)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, _, err := mg.Send(ctx, msg)
_, _, err = mg.Send(ctx, msg)
if err != nil {
return err

View File

@ -2,12 +2,22 @@ package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"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
type Teams struct{}
type Teams struct {
apiURL string
}
// TeamsMessage is representation of json message structure
type TeamsMessage struct {
@ -31,20 +41,16 @@ type TeamsFact struct {
}
// Send is function for sending directly to API
func (t Teams) Send(secret string, i *Information) error {
err := i.Error
var errMessage string
func (t Teams) Send(n *Notification) error {
var selector v1alpha2.SecretKeySelector
secret := &corev1.Secret{}
if err != nil {
errMessage = err.Error()
} else {
errMessage = noErrorMessage
}
i := n.Information
msg, err := json.Marshal(TeamsMessage{
Type: "MessageCard",
Context: "https://schema.org/extensions",
ThemeColor: getStatusColor(i.Status, t),
ThemeColor: getStatusColor(i.LogLevel, t),
Title: titleText,
Sections: []TeamsSection{
{
@ -58,20 +64,33 @@ func (t Teams) Send(secret string, i *Information) error {
Value: i.ConfigurationType,
},
{
Name: statusFieldName,
Value: getStatusName(i.Status),
Name: loggingLevelFieldName,
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 {
return err
}
request, err := http.NewRequest("POST", secret, bytes.NewBuffer(msg))
request, err := http.NewRequest("POST", t.apiURL, bytes.NewBuffer(msg))
if err != nil {
return err
}
@ -81,10 +100,7 @@ func (t Teams) Send(secret string, i *Information) error {
return err
}
err = resp.Body.Close()
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}

View File

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

View File

@ -1,160 +1,132 @@
package notifier
import (
"context"
"fmt"
"net/http"
"github.com/go-logr/logr"
"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"
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"
testStatus Status = 1
testError error
testConfigurationType = "test-configuration"
testCrName = "test-cr"
testNamespace = "test-namespace"
testMessage = "test-message"
testMessageVerbose = "detail-test-message"
testLoggingLevel = LogWarn
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
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
Status Status
Error error
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
// Recipient is mobile number or email address
// It's not used in Slack or Microsoft Teams
Recipient string
Jenkins *v1alpha2.Jenkins
K8sClient k8sclient.Client
Logger logr.Logger
Information *Information
}
// Service is skeleton for additional services
type Service interface {
Send(secret string, i *Information) error
type service interface {
Send(i *Notification) error
}
// Listen is goroutine that listens for incoming messages and sends it
func Listen(notification chan *Notification) {
n := <-notification
if len(n.Jenkins.Spec.Notification) > 0 {
for _, endpoint := range n.Jenkins.Spec.Notification {
var err error
var service Service
var selector v1alpha2.SecretKeySelector
secret := &corev1.Secret{}
<-notification
for n := range notification {
if len(n.Jenkins.Spec.Notification) > 0 {
for _, endpoint := range n.Jenkins.Spec.Notification {
var err error
var svc service
if endpoint.Slack != (v1alpha2.Slack{}) {
n.Logger.V(log.VDebug).Info("Slack detected")
service = Slack{}
selector = endpoint.Slack.URLSecretKeySelector
} else if endpoint.Teams != (v1alpha2.Teams{}) {
n.Logger.V(log.VDebug).Info("Microsoft Teams detected")
service = Teams{}
selector = endpoint.Teams.URLSecretKeySelector
} else if endpoint.Mailgun != (v1alpha2.Mailgun{}) {
n.Logger.V(log.VDebug).Info("Mailgun detected")
service = Mailgun{
Domain: endpoint.Mailgun.Domain,
Recipient: endpoint.Mailgun.Recipient,
From: endpoint.Mailgun.From,
if endpoint.Slack != (v1alpha2.Slack{}) {
svc = Slack{}
} else if endpoint.Teams != (v1alpha2.Teams{}) {
svc = Teams{}
} else if endpoint.Mailgun != (v1alpha2.Mailgun{}) {
svc = Mailgun{
Domain: endpoint.Mailgun.Domain,
Recipient: endpoint.Mailgun.Recipient,
From: endpoint.Mailgun.From,
}
} else {
n.Logger.V(log.VWarn).Info("Notification service not found or not defined")
}
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)
if err != nil {
n.Logger.Info(fmt.Sprintf("Failed to get secret with name `%s`. %+v", selector.Name, err))
}
err = notify(svc, n)
n.Logger.V(log.VDebug).Info(fmt.Sprintf("Endpoint URL: %s", string(secret.Data[selector.Key])))
err = notify(service, string(secret.Data[selector.Key]), n.Information)
if err != nil {
n.Logger.Info(fmt.Sprintf("Failed to send notifications. %+v", err))
} else {
n.Logger.Info("Sent notification")
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 getStatusName(status Status) string {
switch status {
case StatusSuccess:
return "Success"
case StatusError:
return "Error"
default:
return "Undefined"
}
}
func getStatusColor(status Status, service Service) StatusColor {
switch service.(type) {
func getStatusColor(logLevel LoggingLevel, svc service) StatusColor {
switch svc.(type) {
case Slack:
switch status {
case StatusSuccess:
return "good"
case StatusError:
switch logLevel {
case LogInfo:
return "#439FE0"
case LogWarn:
return "danger"
default:
return "#c8c8c8"
}
case Teams:
switch status {
case StatusSuccess:
return "54A254"
case StatusError:
switch logLevel {
case LogInfo:
return "439FE0"
case LogWarn:
return "E81123"
default:
return "C8C8C8"
}
case Mailgun:
switch status {
case StatusSuccess:
return "green"
case StatusError:
switch logLevel {
case LogInfo:
return "blue"
case LogWarn:
return "red"
default:
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
switch svc := service.(type) {
switch s := svc.(type) {
case Slack:
err = svc.Send(secret, i)
err = s.Send(n)
case Teams:
err = svc.Send(secret, i)
err = s.Send(n)
case Mailgun:
err = svc.Send(secret, i)
err = s.Send(n)
}
return err

View File

@ -2,12 +2,22 @@ package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"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
type Slack struct{}
type Slack struct {
apiURL string
}
// SlackMessage is representation of json message
type SlackMessage struct {
@ -34,26 +44,31 @@ type SlackField struct {
}
// Send is function for sending directly to API
func (s Slack) Send(secret string, i *Information) error {
err := i.Error
var errMessage string
func (s Slack) Send(n *Notification) error {
var selector v1alpha2.SecretKeySelector
secret := &corev1.Secret{}
if err != nil {
errMessage = err.Error()
} else {
errMessage = noErrorMessage
i := n.Information
if s.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))
}
s.apiURL = secret.StringData[selector.Name]
}
slackMessage, err := json.Marshal(SlackMessage{
Attachments: []SlackAttachment{
{
Fallback: "",
Color: getStatusColor(i.Status, s),
Color: getStatusColor(i.LogLevel, s),
Text: titleText,
Fields: []SlackField{
{
Title: statusMessageFieldName,
Value: errMessage,
Title: messageFieldName,
Value: i.Message,
Short: false,
},
{
@ -66,6 +81,16 @@ func (s Slack) Send(secret string, i *Information) error {
Value: i.ConfigurationType,
Short: true,
},
{
Title: loggingLevelFieldName,
Value: string(i.LogLevel),
Short: true,
},
{
Title: namespaceFieldName,
Value: i.Namespace,
Short: true,
},
},
Footer: footerContent,
},
@ -76,7 +101,7 @@ func (s Slack) Send(secret string, i *Information) error {
return err
}
request, err := http.NewRequest("POST", secret, bytes.NewBuffer(slackMessage))
request, err := http.NewRequest("POST", s.apiURL, bytes.NewBuffer(slackMessage))
if err != nil {
return err
}
@ -86,10 +111,6 @@ func (s Slack) Send(secret string, i *Information) error {
return err
}
err = resp.Body.Close()
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}

View File

@ -2,20 +2,25 @@ package notifier
import (
"encoding/json"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSlack_Send(t *testing.T) {
slack := Slack{}
i := &Information{
ConfigurationType: testConfigurationType,
CrName: testCrName,
Status: testStatus,
Error: testError,
Message: testMessage,
MessageVerbose: testMessageVerbose,
Namespace: testNamespace,
LogLevel: testLoggingLevel,
}
notification := &Notification{
Information: i,
}
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 {
switch field.Title {
case configurationTypeFieldName:
if field.Value != i.ConfigurationType {
t.Fatalf("%s is not equal! Must be %s", configurationTypeFieldName, i.ConfigurationType)
}
assert.Equal(t, field.Value, i.ConfigurationType)
case crNameFieldName:
if field.Value != i.CrName {
t.Fatalf("%s is not equal! Must be %s", crNameFieldName, i.CrName)
}
case statusMessageFieldName:
if field.Value != noErrorMessage {
t.Fatalf("Error thrown but not expected")
}
assert.Equal(t, field.Value, i.CrName)
case messageFieldName:
assert.Equal(t, field.Value, i.Message)
case loggingLevelFieldName:
assert.Equal(t, field.Value, string(i.LogLevel))
}
}
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()
if err := slack.Send(server.URL, i); err != nil {
t.Fatal(err)
}
slack := Slack{apiURL: server.URL}
assert.NoError(t, slack.Send(notification))
}