From 6d32ee71e4166b30d859447b828d85e2b0450ecc Mon Sep 17 00:00:00 2001 From: Jakub Al-Khalili Date: Tue, 5 Nov 2019 16:06:29 +0100 Subject: [PATCH] Improve notification mechanism (#183) Improve notification mechanism --- cmd/manager/main.go | 3 +- pkg/apis/jenkins/v1alpha2/jenkins_types.go | 26 +- .../jenkins/configuration/base/reconcile.go | 174 ++++++---- .../jenkins/configuration/configuration.go | 19 +- .../jenkins/configuration/user/reconcile.go | 1 - .../configuration/user/seedjobs/seedjobs.go | 11 +- pkg/controller/jenkins/jenkins_controller.go | 90 +++--- .../jenkins/notifications/event/event.go | 34 ++ .../jenkins/notifications/mailgun.go | 96 ------ .../jenkins/notifications/mailgun/mailgun.go | 123 +++++++ .../notifications/mailgun/mailgun_test.go | 182 +++++++++++ .../jenkins/notifications/msteams.go | 133 -------- .../jenkins/notifications/msteams/msteams.go | 147 +++++++++ .../notifications/msteams/msteams_test.go | 287 +++++++++++++++++ .../jenkins/notifications/msteams_test.go | 97 ------ .../notifications/provider/provider.go | 40 +++ .../jenkins/notifications/reason/reason.go | 192 +++++++++++ .../notifications/reason/reason_test.go | 78 +++++ .../jenkins/notifications/sender.go | 140 +++----- pkg/controller/jenkins/notifications/slack.go | 136 -------- .../jenkins/notifications/slack/slack.go | 152 +++++++++ .../jenkins/notifications/slack/slack_test.go | 303 ++++++++++++++++++ .../jenkins/notifications/slack_test.go | 97 ------ pkg/controller/jenkins/notifications/smtp.go | 94 ------ .../jenkins/notifications/smtp/smtp.go | 134 ++++++++ .../notifications/{ => smtp}/smtp_test.go | 174 ++++++++-- 26 files changed, 2049 insertions(+), 914 deletions(-) create mode 100644 pkg/controller/jenkins/notifications/event/event.go delete mode 100644 pkg/controller/jenkins/notifications/mailgun.go create mode 100644 pkg/controller/jenkins/notifications/mailgun/mailgun.go create mode 100644 pkg/controller/jenkins/notifications/mailgun/mailgun_test.go delete mode 100644 pkg/controller/jenkins/notifications/msteams.go create mode 100644 pkg/controller/jenkins/notifications/msteams/msteams.go create mode 100644 pkg/controller/jenkins/notifications/msteams/msteams_test.go delete mode 100644 pkg/controller/jenkins/notifications/msteams_test.go create mode 100644 pkg/controller/jenkins/notifications/provider/provider.go create mode 100644 pkg/controller/jenkins/notifications/reason/reason.go create mode 100644 pkg/controller/jenkins/notifications/reason/reason_test.go delete mode 100644 pkg/controller/jenkins/notifications/slack.go create mode 100644 pkg/controller/jenkins/notifications/slack/slack.go create mode 100644 pkg/controller/jenkins/notifications/slack/slack_test.go delete mode 100644 pkg/controller/jenkins/notifications/slack_test.go delete mode 100644 pkg/controller/jenkins/notifications/smtp.go create mode 100644 pkg/controller/jenkins/notifications/smtp/smtp.go rename pkg/controller/jenkins/notifications/{ => smtp}/smtp_test.go (63%) diff --git a/cmd/manager/main.go b/cmd/manager/main.go index ccab3762..6831f396 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -14,6 +14,7 @@ import ( "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/constants" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications" + e "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" "github.com/jenkinsci/kubernetes-operator/pkg/event" "github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/jenkinsci/kubernetes-operator/version" @@ -119,7 +120,7 @@ func main() { fatal(errors.Wrap(err, "failed to create Kubernetes client set"), *debug) } - c := make(chan notifications.Event) + c := make(chan e.Event) go notifications.Listen(c, events, mgr.GetClient()) // setup Jenkins controller diff --git a/pkg/apis/jenkins/v1alpha2/jenkins_types.go b/pkg/apis/jenkins/v1alpha2/jenkins_types.go index 47489ac4..5ad7a66d 100644 --- a/pkg/apis/jenkins/v1alpha2/jenkins_types.go +++ b/pkg/apis/jenkins/v1alpha2/jenkins_types.go @@ -54,26 +54,26 @@ type JenkinsSpec struct { ConfigurationAsCode ConfigurationAsCode `json:"configurationAsCode,omitempty"` } -// NotificationLogLevel defines logging level of Notification -type NotificationLogLevel string +// NotificationLevel defines the level of a Notification +type NotificationLevel string const ( - // NotificationLogLevelWarning - Only Warnings - NotificationLogLevelWarning NotificationLogLevel = "warning" + // NotificationLevelWarning - Only Warnings + NotificationLevelWarning NotificationLevel = "warning" - // NotificationLogLevelInfo - Only info - NotificationLogLevelInfo NotificationLogLevel = "info" + // NotificationLevelInfo - Only info + NotificationLevelInfo NotificationLevel = "info" ) // Notification is a service configuration used to send notifications about Jenkins status type Notification struct { - LoggingLevel NotificationLogLevel `json:"loggingLevel"` - Verbose bool `json:"verbose"` - Name string `json:"name"` - Slack *Slack `json:"slack,omitempty"` - Teams *MicrosoftTeams `json:"teams,omitempty"` - Mailgun *Mailgun `json:"mailgun,omitempty"` - SMTP *SMTP `json:"smtp,omitempty"` + LoggingLevel NotificationLevel `json:"level"` + Verbose bool `json:"verbose"` + Name string `json:"name"` + Slack *Slack `json:"slack,omitempty"` + Teams *MicrosoftTeams `json:"teams,omitempty"` + Mailgun *Mailgun `json:"mailgun,omitempty"` + SMTP *SMTP `json:"smtp,omitempty"` } // Slack is handler for Slack notification channel diff --git a/pkg/controller/jenkins/configuration/base/reconcile.go b/pkg/controller/jenkins/configuration/base/reconcile.go index dbdc0a78..3d2627e0 100644 --- a/pkg/controller/jenkins/configuration/base/reconcile.go +++ b/pkg/controller/jenkins/configuration/base/reconcile.go @@ -15,7 +15,8 @@ import ( "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/backuprestore" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy" - "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/plugins" "github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/jenkinsci/kubernetes-operator/version" @@ -106,8 +107,14 @@ func (r *ReconcileJenkinsBaseConfiguration) Reconcile() (reconcile.Result, jenki return reconcile.Result{}, nil, err } if !ok { - r.logger.Info("Some plugins have changed, restarting Jenkins") - return reconcile.Result{Requeue: true}, nil, r.Configuration.RestartJenkinsMasterPod() + message := "Some plugins have changed, restarting Jenkins" + r.logger.Info(message) + + restartReason := reason.NewPodRestart( + reason.OperatorSource, + []string{message}, + ) + return reconcile.Result{Requeue: true}, nil, r.Configuration.RestartJenkinsMasterPod(restartReason) } result, err = r.ensureBaseConfiguration(jenkinsClient) @@ -402,11 +409,11 @@ func (r *ReconcileJenkinsBaseConfiguration) ensureJenkinsMasterPod(meta metav1.O resources.JenkinsMasterContainerName, []string{"bash", "-c", fmt.Sprintf("%s/%s && && /sbin/tini -s -- /usr/local/bin/jenkins.sh", resources.JenkinsScriptsVolumePath, resources.InitScriptName)})) } - *r.Notifications <- notifications.Event{ - Jenkins: *r.Configuration.Jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelInfo, - Message: "Creating a new Jenkins Master Pod", + *r.Notifications <- event.Event{ + Jenkins: *r.Configuration.Jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelInfo, + Reason: reason.NewPodCreation(reason.OperatorSource, []string{"Creating a new Jenkins Master Pod"}), } r.logger.Info(fmt.Sprintf("Creating a new Jenkins Master Pod %s/%s", jenkinsMasterPod.Namespace, jenkinsMasterPod.Name)) err = r.createResource(jenkinsMasterPod) @@ -452,12 +459,13 @@ func (r *ReconcileJenkinsBaseConfiguration) ensureJenkinsMasterPod(meta metav1.O return reconcile.Result{Requeue: true}, nil } - messages := r.isRecreatePodNeeded(*currentJenkinsMasterPod, userAndPasswordHash) - if hasMessages := len(messages) > 0; hasMessages { - for _, msg := range messages { + restartReason := r.checkForPodRecreation(*currentJenkinsMasterPod, userAndPasswordHash) + if restartReason.HasMessages() { + for _, msg := range restartReason.Verbose() { r.logger.Info(msg) } - return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod() + + return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod(restartReason) } return reconcile.Result{}, nil @@ -480,59 +488,76 @@ func isPodTerminating(pod corev1.Pod) bool { return pod.ObjectMeta.DeletionTimestamp != nil } -func (r *ReconcileJenkinsBaseConfiguration) isRecreatePodNeeded(currentJenkinsMasterPod corev1.Pod, userAndPasswordHash string) []string { +func (r *ReconcileJenkinsBaseConfiguration) checkForPodRecreation(currentJenkinsMasterPod corev1.Pod, userAndPasswordHash string) reason.Reason { var messages []string - if userAndPasswordHash != r.Configuration.Jenkins.Status.UserAndPasswordHash { - messages = append(messages, "User or password have changed, recreating pod") - } - - if r.Configuration.Jenkins.Spec.Restore.RecoveryOnce != 0 && r.Configuration.Jenkins.Status.RestoredBackup != 0 { - messages = append(messages, "spec.restore.recoveryOnce is set, recreating pod") - } - - if version.Version != r.Configuration.Jenkins.Status.OperatorVersion { - messages = append(messages, fmt.Sprintf("Jenkins Operator version has changed, actual '%+v' new '%+v', recreating pod", - r.Configuration.Jenkins.Status.OperatorVersion, version.Version)) - } + var verbose []string if currentJenkinsMasterPod.Status.Phase == corev1.PodFailed || currentJenkinsMasterPod.Status.Phase == corev1.PodSucceeded || currentJenkinsMasterPod.Status.Phase == corev1.PodUnknown { - messages = append(messages, fmt.Sprintf("Invalid Jenkins pod phase '%+v', recreating pod", currentJenkinsMasterPod.Status)) + messages = append(messages, fmt.Sprintf("Invalid Jenkins pod phase '%s'", currentJenkinsMasterPod.Status.Phase)) + verbose = append(verbose, fmt.Sprintf("Invalid Jenkins pod phase '%+v'", currentJenkinsMasterPod.Status)) + return reason.NewPodRestart(reason.KubernetesSource, messages, verbose...) + } + + if userAndPasswordHash != r.Configuration.Jenkins.Status.UserAndPasswordHash { + messages = append(messages, "User or password have changed") + verbose = append(verbose, "User or password have changed, recreating pod") + } + + if r.Configuration.Jenkins.Spec.Restore.RecoveryOnce != 0 && r.Configuration.Jenkins.Status.RestoredBackup != 0 { + messages = append(messages, "spec.restore.recoveryOnce is set") + verbose = append(verbose, "spec.restore.recoveryOnce is set, recreating pod") + } + + if version.Version != r.Configuration.Jenkins.Status.OperatorVersion { + messages = append(messages, "Jenkins Operator version has changed") + verbose = append(verbose, fmt.Sprintf("Jenkins Operator version has changed, actual '%+v' new '%+v'", + r.Configuration.Jenkins.Status.OperatorVersion, version.Version)) } if !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.SecurityContext, currentJenkinsMasterPod.Spec.SecurityContext) { - messages = append(messages, fmt.Sprintf("Jenkins pod security context has changed, actual '%+v' required '%+v', recreating pod", + messages = append(messages, fmt.Sprintf("Jenkins pod security context has changed")) + verbose = append(verbose, fmt.Sprintf("Jenkins pod security context has changed, actual '%+v' required '%+v'", currentJenkinsMasterPod.Spec.SecurityContext, r.Configuration.Jenkins.Spec.Master.SecurityContext)) } if !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.ImagePullSecrets, currentJenkinsMasterPod.Spec.ImagePullSecrets) { - messages = append(messages, fmt.Sprintf("Jenkins Pod ImagePullSecrets has changed, actual '%+v' required '%+v', recreating pod", + messages = append(messages, "Jenkins Pod ImagePullSecrets has changed") + verbose = append(verbose, fmt.Sprintf("Jenkins Pod ImagePullSecrets has changed, actual '%+v' required '%+v'", currentJenkinsMasterPod.Spec.ImagePullSecrets, r.Configuration.Jenkins.Spec.Master.ImagePullSecrets)) } if !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.NodeSelector, currentJenkinsMasterPod.Spec.NodeSelector) { - messages = append(messages, fmt.Sprintf("Jenkins pod node selector has changed, actual '%+v' required '%+v', recreating pod", + messages = append(messages, "Jenkins pod node selector has changed") + verbose = append(verbose, fmt.Sprintf("Jenkins pod node selector has changed, actual '%+v' required '%+v'", currentJenkinsMasterPod.Spec.NodeSelector, r.Configuration.Jenkins.Spec.Master.NodeSelector)) } if len(r.Configuration.Jenkins.Spec.Master.Annotations) > 0 && !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.Annotations, currentJenkinsMasterPod.ObjectMeta.Annotations) { - messages = append(messages, fmt.Sprintf("Jenkins pod annotations have changed to '%+v', recreating pod", r.Configuration.Jenkins.Spec.Master.Annotations)) + messages = append(messages, "Jenkins pod annotations have changed") + verbose = append(verbose, fmt.Sprintf("Jenkins pod annotations have changed, actual '%+v' required '%+v'", + currentJenkinsMasterPod.ObjectMeta.Annotations, r.Configuration.Jenkins.Spec.Master.Annotations)) } if !r.compareVolumes(currentJenkinsMasterPod) { - messages = append(messages, fmt.Sprintf("Jenkins pod volumes have changed, actual '%v' required '%v', recreating pod", + messages = append(messages, "Jenkins pod volumes have changed") + verbose = append(verbose, fmt.Sprintf("Jenkins pod volumes have changed, actual '%v' required '%v'", currentJenkinsMasterPod.Spec.Volumes, r.Configuration.Jenkins.Spec.Master.Volumes)) } if len(r.Configuration.Jenkins.Spec.Master.Containers) != len(currentJenkinsMasterPod.Spec.Containers) { - messages = append(messages, fmt.Sprintf("Jenkins amount of containers has changed to '%+v', recreating pod", len(r.Configuration.Jenkins.Spec.Master.Containers))) + messages = append(messages, "Jenkins amount of containers has changed") + verbose = append(verbose, fmt.Sprintf("Jenkins amount of containers has changed, actual '%+v' required '%+v'", + len(currentJenkinsMasterPod.Spec.Containers), len(r.Configuration.Jenkins.Spec.Master.Containers))) } for _, actualContainer := range currentJenkinsMasterPod.Spec.Containers { if actualContainer.Name == resources.JenkinsMasterContainerName { - messages = append(messages, r.compareContainers(resources.NewJenkinsMasterContainer(r.Configuration.Jenkins), actualContainer)...) + containerMessages, verboseMessages := r.compareContainers(resources.NewJenkinsMasterContainer(r.Configuration.Jenkins), actualContainer) + messages = append(messages, containerMessages...) + verbose = append(verbose, verboseMessages...) continue } @@ -545,76 +570,79 @@ func (r *ReconcileJenkinsBaseConfiguration) isRecreatePodNeeded(currentJenkinsMa } if expectedContainer == nil { - messages = append(messages, fmt.Sprintf("Container '%+v' not found in pod, recreating pod", actualContainer)) + messages = append(messages, fmt.Sprintf("Container '%s' not found in pod", actualContainer.Name)) + verbose = append(verbose, fmt.Sprintf("Container '%+v' not found in pod", actualContainer)) + continue } - messages = append(messages, r.compareContainers(*expectedContainer, actualContainer)...) + containerMessages, verboseMessages := r.compareContainers(*expectedContainer, actualContainer) + + messages = append(messages, containerMessages...) + verbose = append(verbose, verboseMessages...) } - return messages + return reason.NewPodRestart(reason.KubernetesSource, messages, verbose...) } -func (r *ReconcileJenkinsBaseConfiguration) compareContainers(expected corev1.Container, actual corev1.Container) []string { - var messages []string - +func (r *ReconcileJenkinsBaseConfiguration) compareContainers(expected corev1.Container, actual corev1.Container) (messages []string, verbose []string) { if !reflect.DeepEqual(expected.Args, actual.Args) { - r.logger.Info(fmt.Sprintf("Arguments have changed to '%+v' in container '%s', recreating pod", expected.Args, expected.Name)) - messages = append(messages, fmt.Sprintf("Arguments have changed to '%+v' in container '%s', recreating pod", expected.Args, expected.Name)) + messages = append(messages, "Arguments have changed") + verbose = append(messages, fmt.Sprintf("Arguments have changed to '%+v' in container '%s'", expected.Args, expected.Name)) } if !reflect.DeepEqual(expected.Command, actual.Command) { - r.logger.Info(fmt.Sprintf("Command has changed to '%+v' in container '%s', recreating pod", expected.Command, expected.Name)) - messages = append(messages, fmt.Sprintf("Command has changed to '%+v' in container '%s', recreating pod", expected.Command, expected.Name)) + messages = append(messages, "Command has changed") + verbose = append(verbose, fmt.Sprintf("Command has changed to '%+v' in container '%s'", expected.Command, expected.Name)) } if !compareEnv(expected.Env, actual.Env) { - r.logger.Info(fmt.Sprintf("Env has changed to '%+v' in container '%s', recreating pod", expected.Env, expected.Name)) - messages = append(messages, fmt.Sprintf("Env has changed to '%+v' in container '%s', recreating pod", expected.Env, expected.Name)) + messages = append(messages, "Env has changed") + verbose = append(verbose, fmt.Sprintf("Env has changed to '%+v' in container '%s'", expected.Env, expected.Name)) } if !reflect.DeepEqual(expected.EnvFrom, actual.EnvFrom) { - r.logger.Info(fmt.Sprintf("EnvFrom has changed to '%+v' in container '%s', recreating pod", expected.EnvFrom, expected.Name)) - messages = append(messages, fmt.Sprintf("EnvFrom has changed to '%+v' in container '%s', recreating pod", expected.EnvFrom, expected.Name)) + messages = append(messages, "EnvFrom has changed") + verbose = append(verbose, fmt.Sprintf("EnvFrom has changed to '%+v' in container '%s'", expected.EnvFrom, expected.Name)) } if !reflect.DeepEqual(expected.Image, actual.Image) { - r.logger.Info(fmt.Sprintf("Image has changed to '%+v' in container '%s', recreating pod", expected.Image, expected.Name)) - messages = append(messages, fmt.Sprintf("Image has changed to '%+v' in container '%s', recreating pod", expected.Image, expected.Name)) + messages = append(messages, "Image has changed") + verbose = append(verbose, fmt.Sprintf("Image has changed to '%+v' in container '%s'", expected.Image, expected.Name)) } if !reflect.DeepEqual(expected.ImagePullPolicy, actual.ImagePullPolicy) { - r.logger.Info(fmt.Sprintf("Image pull policy has changed to '%+v' in container '%s', recreating pod", expected.ImagePullPolicy, expected.Name)) - messages = append(messages, fmt.Sprintf("Image pull policy has changed to '%+v' in container '%s', recreating pod", expected.ImagePullPolicy, expected.Name)) + messages = append(messages, "Image pull policy has changed") + verbose = append(verbose, fmt.Sprintf("Image pull policy has changed to '%+v' in container '%s'", expected.ImagePullPolicy, expected.Name)) } if !reflect.DeepEqual(expected.Lifecycle, actual.Lifecycle) { - r.logger.Info(fmt.Sprintf("Lifecycle has changed to '%+v' in container '%s', recreating pod", expected.Lifecycle, expected.Name)) - messages = append(messages, fmt.Sprintf("Lifecycle has changed to '%+v' in container '%s', recreating pod", expected.Lifecycle, expected.Name)) + messages = append(messages, "Lifecycle has changed") + verbose = append(verbose, fmt.Sprintf("Lifecycle has changed to '%+v' in container '%s'", expected.Lifecycle, expected.Name)) } if !reflect.DeepEqual(expected.LivenessProbe, actual.LivenessProbe) { - r.logger.Info(fmt.Sprintf("Liveness probe has changed to '%+v' in container '%s', recreating pod", expected.LivenessProbe, expected.Name)) - messages = append(messages, fmt.Sprintf("Liveness probe has changed to '%+v' in container '%s', recreating pod", expected.LivenessProbe, expected.Name)) + messages = append(messages, "Liveness probe has changed") + verbose = append(verbose, fmt.Sprintf("Liveness probe has changed to '%+v' in container '%s'", expected.LivenessProbe, expected.Name)) } if !reflect.DeepEqual(expected.Ports, actual.Ports) { - r.logger.Info(fmt.Sprintf("Ports have changed to '%+v' in container '%s', recreating pod", expected.Ports, expected.Name)) - messages = append(messages, fmt.Sprintf("Ports have changed to '%+v' in container '%s', recreating pod", expected.Ports, expected.Name)) + messages = append(messages, "Ports have changed") + verbose = append(verbose, fmt.Sprintf("Ports have changed to '%+v' in container '%s'", expected.Ports, expected.Name)) } if !reflect.DeepEqual(expected.ReadinessProbe, actual.ReadinessProbe) { - r.logger.Info(fmt.Sprintf("Readiness probe has changed to '%+v' in container '%s', recreating pod", expected.ReadinessProbe, expected.Name)) - messages = append(messages, fmt.Sprintf("Readiness probe has changed to '%+v' in container '%s', recreating pod", expected.ReadinessProbe, expected.Name)) + messages = append(messages, "Readiness probe has changed") + verbose = append(verbose, fmt.Sprintf("Readiness probe has changed to '%+v' in container '%s'", expected.ReadinessProbe, expected.Name)) } if !reflect.DeepEqual(expected.Resources, actual.Resources) { - r.logger.Info(fmt.Sprintf("Resources have changed to '%+v' in container '%s', recreating pod", expected.Resources, expected.Name)) - messages = append(messages, fmt.Sprintf("Resources have changed to '%+v' in container '%s', recreating pod", expected.Resources, expected.Name)) + messages = append(messages, "Resources have changed") + verbose = append(verbose, fmt.Sprintf("Resources have changed to '%+v' in container '%s'", expected.Resources, expected.Name)) } if !reflect.DeepEqual(expected.SecurityContext, actual.SecurityContext) { - r.logger.Info(fmt.Sprintf("Security context has changed to '%+v' in container '%s', recreating pod", expected.SecurityContext, expected.Name)) - messages = append(messages, fmt.Sprintf("Security context has changed to '%+v' in container '%s', recreating pod", expected.SecurityContext, expected.Name)) + messages = append(messages, "Security context has changed") + verbose = append(verbose, fmt.Sprintf("Security context has changed to '%+v' in container '%s'", expected.SecurityContext, expected.Name)) } if !reflect.DeepEqual(expected.WorkingDir, actual.WorkingDir) { - r.logger.Info(fmt.Sprintf("Working directory has changed to '%+v' in container '%s', recreating pod", expected.WorkingDir, expected.Name)) - messages = append(messages, fmt.Sprintf("Working directory has changed to '%+v' in container '%s', recreating pod", expected.WorkingDir, expected.Name)) + messages = append(messages, "Working directory has changed") + verbose = append(verbose, fmt.Sprintf("Working directory has changed to '%+v' in container '%s'", expected.WorkingDir, expected.Name)) } if !CompareContainerVolumeMounts(expected, actual) { - r.logger.Info(fmt.Sprintf("Volume mounts have changed to '%+v' in container '%s', recreating pod", expected.VolumeMounts, expected.Name)) - messages = append(messages, fmt.Sprintf("Volume mounts have changed to '%+v' in container '%s', recreating pod", expected.VolumeMounts, expected.Name)) + messages = append(messages, "Volume mounts have changed") + verbose = append(verbose, fmt.Sprintf("Volume mounts have changed to '%+v' in container '%s'", expected.VolumeMounts, expected.Name)) } - return messages + return messages, verbose } func compareEnv(expected, actual []corev1.EnvVar) bool { @@ -723,8 +751,14 @@ func (r *ReconcileJenkinsBaseConfiguration) waitForJenkins(meta metav1.ObjectMet containersReadyCount := 0 for _, containerStatus := range jenkinsMasterPod.Status.ContainerStatuses { if containerStatus.State.Terminated != nil { - r.logger.Info(fmt.Sprintf("Container '%s' is terminated, status '%+v', recreating pod", containerStatus.Name, containerStatus)) - return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod() + message := fmt.Sprintf("Container '%s' is terminated, status '%+v'", containerStatus.Name, containerStatus) + r.logger.Info(message) + + restartReason := reason.NewPodRestart( + reason.KubernetesSource, + []string{message}, + ) + return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod(restartReason) } if !containerStatus.Ready { r.logger.V(log.VDebug).Info(fmt.Sprintf("Container '%s' not ready, readiness probe failed", containerStatus.Name)) diff --git a/pkg/controller/jenkins/configuration/configuration.go b/pkg/controller/jenkins/configuration/configuration.go index ebba6341..efa26962 100644 --- a/pkg/controller/jenkins/configuration/configuration.go +++ b/pkg/controller/jenkins/configuration/configuration.go @@ -2,11 +2,11 @@ package configuration import ( "context" - "fmt" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" - "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" stackerr "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" @@ -19,23 +19,22 @@ import ( type Configuration struct { Client client.Client ClientSet kubernetes.Clientset - Notifications *chan notifications.Event + Notifications *chan event.Event Jenkins *v1alpha2.Jenkins } // RestartJenkinsMasterPod terminate Jenkins master pod and notifies about it -func (c *Configuration) RestartJenkinsMasterPod() error { +func (c *Configuration) RestartJenkinsMasterPod(reason reason.Reason) error { currentJenkinsMasterPod, err := c.getJenkinsMasterPod() if err != nil { return err } - *c.Notifications <- notifications.Event{ - Jenkins: *c.Jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelInfo, - Message: fmt.Sprintf("Terminating Jenkins Master Pod %s/%s.", currentJenkinsMasterPod.Namespace, currentJenkinsMasterPod.Name), - MessagesVerbose: []string{}, + *c.Notifications <- event.Event{ + Jenkins: *c.Jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelInfo, + Reason: reason, } return stackerr.WithStack(c.Client.Delete(context.TODO(), currentJenkinsMasterPod)) diff --git a/pkg/controller/jenkins/configuration/user/reconcile.go b/pkg/controller/jenkins/configuration/user/reconcile.go index 48708e96..b6af504d 100644 --- a/pkg/controller/jenkins/configuration/user/reconcile.go +++ b/pkg/controller/jenkins/configuration/user/reconcile.go @@ -3,7 +3,6 @@ package user import ( "strings" - jenkinsclient "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/client" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/backuprestore" diff --git a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go index b864f117..cb07281e 100644 --- a/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go +++ b/pkg/controller/jenkins/configuration/user/seedjobs/seedjobs.go @@ -15,6 +15,7 @@ import ( "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/constants" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" "github.com/go-logr/logr" stackerr "github.com/pkg/errors" @@ -148,8 +149,14 @@ func New(jenkinsClient jenkinsclient.Jenkins, config configuration.Configuration // EnsureSeedJobs configures seed job and runs it for every entry from Jenkins.Spec.SeedJobs func (s *SeedJobs) EnsureSeedJobs(jenkins *v1alpha2.Jenkins) (done bool, err error) { if s.isRecreatePodNeeded(*jenkins) { - s.logger.Info("Some seed job has been deleted, recreating pod") - return false, s.RestartJenkinsMasterPod() + message := "Some seed job has been deleted, recreating pod" + s.logger.Info(message) + + restartReason := reason.NewPodRestart( + reason.OperatorSource, + []string{message}, + ) + return false, s.RestartJenkinsMasterPod(restartReason) } if len(jenkins.Spec.SeedJobs) > 0 { diff --git a/pkg/controller/jenkins/jenkins_controller.go b/pkg/controller/jenkins/jenkins_controller.go index 5a2fcad7..ce0493a6 100644 --- a/pkg/controller/jenkins/jenkins_controller.go +++ b/pkg/controller/jenkins/jenkins_controller.go @@ -12,7 +12,8 @@ import ( "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/user" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/constants" - "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/plugins" "github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/jenkinsci/kubernetes-operator/version" @@ -44,12 +45,12 @@ var reconcileErrors = map[string]reconcileError{} // Add creates a new Jenkins Controller and adds it to the Manager. The Manager will set fields on the Controller // and Start it when the Manager is Started. -func Add(mgr manager.Manager, local, minikube bool, clientSet kubernetes.Clientset, config rest.Config, notificationEvents *chan notifications.Event) error { +func Add(mgr manager.Manager, local, minikube bool, clientSet kubernetes.Clientset, config rest.Config, notificationEvents *chan event.Event) error { return add(mgr, newReconciler(mgr, local, minikube, clientSet, config, notificationEvents)) } // newReconciler returns a new reconcile.Reconciler -func newReconciler(mgr manager.Manager, local, minikube bool, clientSet kubernetes.Clientset, config rest.Config, notificationEvents *chan notifications.Event) reconcile.Reconciler { +func newReconciler(mgr manager.Manager, local, minikube bool, clientSet kubernetes.Clientset, config rest.Config, notificationEvents *chan event.Event) reconcile.Reconciler { return &ReconcileJenkins{ client: mgr.GetClient(), scheme: mgr.GetScheme(), @@ -116,7 +117,7 @@ type ReconcileJenkins struct { local, minikube bool clientSet kubernetes.Clientset config rest.Config - notificationEvents *chan notifications.Event + notificationEvents *chan event.Event } // Reconcile it's a main reconciliation loop which maintain desired state based on Jenkins.Spec @@ -151,12 +152,14 @@ func (r *ReconcileJenkins) Reconcile(request reconcile.Request) (reconcile.Resul logger.V(log.VWarn).Info(fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %s", reconcileFailLimit, err)) } - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelWarning, - Message: fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %s", reconcileFailLimit, err), - MessagesVerbose: []string{fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %+v", reconcileFailLimit, err)}, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelWarning, + Reason: reason.NewReconcileLoopFailed( + reason.OperatorSource, + []string{fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %s", reconcileFailLimit, err)}, + ), } return reconcile.Result{Requeue: false}, nil } @@ -170,12 +173,15 @@ func (r *ReconcileJenkins) Reconcile(request reconcile.Request) (reconcile.Resul } if groovyErr, ok := err.(*jenkinsclient.GroovyScriptExecutionFailed); ok { - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelWarning, - Message: fmt.Sprintf("%s Source '%s' Name '%s' groovy script execution failed, logs:", groovyErr.ConfigurationType, groovyErr.Source, groovyErr.Name), - MessagesVerbose: []string{groovyErr.Logs}, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelWarning, + Reason: reason.NewGroovyScriptExecutionFailed( + reason.OperatorSource, + []string{fmt.Sprintf("%s Source '%s' Name '%s' groovy script execution failed", groovyErr.ConfigurationType, groovyErr.Source, groovyErr.Name)}, + []string{fmt.Sprintf("%s Source '%s' Name '%s' groovy script execution failed, logs: %+v", groovyErr.ConfigurationType, groovyErr.Source, groovyErr.Name, groovyErr.Logs)}..., + ), } return reconcile.Result{Requeue: false}, nil } @@ -213,21 +219,20 @@ func (r *ReconcileJenkins) reconcile(request reconcile.Request, logger logr.Logg // Reconcile base configuration baseConfiguration := base.New(config, r.scheme, logger, r.local, r.minikube, &r.config) - messages, err := baseConfiguration.Validate(jenkins) + baseMessages, err := baseConfiguration.Validate(jenkins) if err != nil { return reconcile.Result{}, jenkins, err } - if len(messages) > 0 { + if len(baseMessages) > 0 { message := "Validation of base configuration failed, please correct Jenkins CR." - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelWarning, - Message: message, - MessagesVerbose: messages, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelWarning, + Reason: reason.NewBaseConfigurationFailed(reason.HumanSource, []string{message}, append([]string{message}, baseMessages...)...), } logger.V(log.VWarn).Info(message) - for _, msg := range messages { + for _, msg := range baseMessages { logger.V(log.VWarn).Info(msg) } return reconcile.Result{}, jenkins, nil // don't requeue @@ -254,30 +259,28 @@ func (r *ReconcileJenkins) reconcile(request reconcile.Request, logger logr.Logg message := fmt.Sprintf("Base configuration phase is complete, took %s", jenkins.Status.BaseConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time)) - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseBase, - LogLevel: v1alpha2.NotificationLogLevelInfo, - Message: message, - MessagesVerbose: messages, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseBase, + Level: v1alpha2.NotificationLevelInfo, + Reason: reason.NewBaseConfigurationComplete(reason.OperatorSource, []string{message}), } logger.Info(message) } // Reconcile user configuration userConfiguration := user.New(config, jenkinsClient, logger, r.config) - messages, err = userConfiguration.Validate(jenkins) + messages, err := userConfiguration.Validate(jenkins) if err != nil { return reconcile.Result{}, jenkins, err } if len(messages) > 0 { message := fmt.Sprintf("Validation of user configuration failed, please correct Jenkins CR") - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseUser, - LogLevel: v1alpha2.NotificationLogLevelWarning, - Message: message, - MessagesVerbose: messages, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseUser, + Level: v1alpha2.NotificationLevelWarning, + Reason: reason.NewUserConfigurationFailed(reason.HumanSource, []string{message}, append([]string{message}, messages...)...), } logger.V(log.VWarn).Info(message) @@ -304,12 +307,11 @@ func (r *ReconcileJenkins) reconcile(request reconcile.Request, logger logr.Logg } message := fmt.Sprintf("User configuration phase is complete, took %s", jenkins.Status.UserConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time)) - *r.notificationEvents <- notifications.Event{ - Jenkins: *jenkins, - Phase: notifications.PhaseUser, - LogLevel: v1alpha2.NotificationLogLevelInfo, - Message: message, - MessagesVerbose: messages, + *r.notificationEvents <- event.Event{ + Jenkins: *jenkins, + Phase: event.PhaseUser, + Level: v1alpha2.NotificationLevelInfo, + Reason: reason.NewUserConfigurationComplete(reason.OperatorSource, []string{message}), } logger.Info(message) } diff --git a/pkg/controller/jenkins/notifications/event/event.go b/pkg/controller/jenkins/notifications/event/event.go new file mode 100644 index 00000000..36af5013 --- /dev/null +++ b/pkg/controller/jenkins/notifications/event/event.go @@ -0,0 +1,34 @@ +package event + +import ( + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" +) + +// Phase defines the context where notification has been generated: base or user. +type Phase string + +// 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 + Phase Phase + Level v1alpha2.NotificationLevel + Reason reason.Reason +} + +const ( + // PhaseBase is core configuration of Jenkins provided by the Operator + PhaseBase Phase = "base" + + // PhaseUser is user-defined configuration of Jenkins + PhaseUser Phase = "user" + + // PhaseUnknown is untraceable type of configuration + PhaseUnknown Phase = "unknown" +) diff --git a/pkg/controller/jenkins/notifications/mailgun.go b/pkg/controller/jenkins/notifications/mailgun.go deleted file mode 100644 index 3658abd9..00000000 --- a/pkg/controller/jenkins/notifications/mailgun.go +++ /dev/null @@ -1,96 +0,0 @@ -package notifications - -import ( - "context" - "fmt" - "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 = ` - - - - -

%s

-

%s

- - - - - - - - - -
CR name:%s
Phase:%s
-
Powered by Jenkins Operator <3
- -` - -// MailGun is a sending emails notification service -type MailGun struct { - k8sClient k8sclient.Client -} - -func (m MailGun) getStatusColor(logLevel v1alpha2.NotificationLogLevel) StatusColor { - switch logLevel { - case v1alpha2.NotificationLogLevelInfo: - return "blue" - case v1alpha2.NotificationLogLevelWarning: - return "red" - default: - return "gray" - } -} - -// Send is function for sending directly to API -func (m MailGun) Send(event Event, config v1alpha2.Notification) error { - secret := &corev1.Secret{} - - selector := config.Mailgun.APIKeySecretKeySelector - - err := m.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) - if err != nil { - return errors.WithStack(err) - } - - secretValue := string(secret.Data[selector.Key]) - if secretValue == "" { - return errors.Errorf("Mailgun API secret is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, selector.Name, selector.Key) - } - - mg := mailgun.NewMailgun(config.Mailgun.Domain, secretValue) - - var statusMessage string - - if config.Verbose { - message := event.Message + "" - statusMessage = message - } else { - statusMessage = event.Message - } - - htmlMessage := fmt.Sprintf(content, m.getStatusColor(event.LogLevel), notificationTitle(event), statusMessage, event.Jenkins.Name, event.Phase) - - msg := mg.NewMessage(fmt.Sprintf("Jenkins Operator Notifier <%s>", config.Mailgun.From), notificationTitle(event), "", config.Mailgun.Recipient) - msg.SetHtml(htmlMessage) - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - _, _, err = mg.Send(ctx, msg) - - return err -} diff --git a/pkg/controller/jenkins/notifications/mailgun/mailgun.go b/pkg/controller/jenkins/notifications/mailgun/mailgun.go new file mode 100644 index 00000000..5dc5dc4e --- /dev/null +++ b/pkg/controller/jenkins/notifications/mailgun/mailgun.go @@ -0,0 +1,123 @@ +package mailgun + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + + "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 ( + infoColor = "blue" + warningColor = "red" + defaultColor = "gray" + + content = ` + + + + +

%s

+

%s

+ + + + + + + + + +
CR name:%s
Phase:%s
+
Powered by Jenkins Operator <3
+ +` +) + +// MailGun is a sending emails notification service +type MailGun struct { + k8sClient k8sclient.Client + config v1alpha2.Notification +} + +// New returns instance of MailGun +func New(k8sClient k8sclient.Client, config v1alpha2.Notification) *MailGun { + return &MailGun{k8sClient: k8sClient, config: config} +} + +func (m MailGun) getStatusColor(logLevel v1alpha2.NotificationLevel) event.StatusColor { + switch logLevel { + case v1alpha2.NotificationLevelInfo: + return infoColor + case v1alpha2.NotificationLevelWarning: + return warningColor + default: + return defaultColor + } +} + +func (m MailGun) generateMessage(event event.Event) string { + var statusMessage strings.Builder + var reasons string + + if m.config.Verbose { + reasons = strings.TrimRight(strings.Join(event.Reason.Verbose(), "
  • "), "
  • ") + } else { + reasons = strings.TrimRight(strings.Join(event.Reason.Short(), "
  • "), "
  • ") + } + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(reasons) + statusMessage.WriteString("
    ") + + statusColor := m.getStatusColor(event.Level) + messageTitle := provider.NotificationTitle(event) + message := statusMessage.String() + crName := event.Jenkins.Name + phase := event.Phase + + return fmt.Sprintf(content, statusColor, messageTitle, message, crName, phase) +} + +// Send is function for sending directly to API +func (m MailGun) Send(event event.Event) error { + secret := &corev1.Secret{} + selector := m.config.Mailgun.APIKeySecretKeySelector + + err := m.k8sClient.Get(context.TODO(), + types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) + if err != nil { + return errors.WithStack(err) + } + + secretValue := string(secret.Data[selector.Key]) + if secretValue == "" { + return errors.Errorf("Mailgun API secret is empty in secret '%s/%s[%s]", + event.Jenkins.Namespace, selector.Name, selector.Key) + } + + mg := mailgun.NewMailgun(m.config.Mailgun.Domain, secretValue) + from := fmt.Sprintf("Jenkins Operator Notifier <%s>", m.config.Mailgun.From) + subject := provider.NotificationTitle(event) + recipient := m.config.Mailgun.Recipient + + msg := mg.NewMessage(from, subject, "", recipient) + msg.SetHtml(m.generateMessage(event)) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + _, _, err = mg.Send(ctx, msg) + + return err +} diff --git a/pkg/controller/jenkins/notifications/mailgun/mailgun_test.go b/pkg/controller/jenkins/notifications/mailgun/mailgun_test.go new file mode 100644 index 00000000..3f428732 --- /dev/null +++ b/pkg/controller/jenkins/notifications/mailgun/mailgun_test.go @@ -0,0 +1,182 @@ +package mailgun + +import ( + "fmt" + "strings" + "testing" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestGenerateMessages(t *testing.T) { + t.Run("happy", func(t *testing.T) { + crName := "test-jenkins" + crNamespace := "test-namespace" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"test-string"}, "test-verbose") + + s := MailGun{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + var statusMessage strings.Builder + r := strings.TrimRight(strings.Join(e.Reason.Verbose(), "
  • "), "
  • ") + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(r) + statusMessage.WriteString("
    ") + + want := s.generateMessage(e) + + got := fmt.Sprintf(content, s.getStatusColor(e.Level), + provider.NotificationTitle(e), statusMessage.String(), e.Jenkins.Name, e.Phase) + + assert.Equal(t, want, got) + }) + + t.Run("with nils", func(t *testing.T) { + crName := "nil" + crNamespace := "nil" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"nil"}, "nil") + + s := MailGun{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + var statusMessage strings.Builder + r := strings.TrimRight(strings.Join(e.Reason.Verbose(), "
  • "), "
  • ") + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(r) + statusMessage.WriteString("
    ") + + want := s.generateMessage(e) + + got := fmt.Sprintf(content, s.getStatusColor(e.Level), + provider.NotificationTitle(e), statusMessage.String(), e.Jenkins.Name, e.Phase) + + assert.Equal(t, want, got) + }) + + t.Run("with empty strings", func(t *testing.T) { + crName := "" + crNamespace := "" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{""}, "") + + s := MailGun{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + var statusMessage strings.Builder + r := strings.TrimRight(strings.Join(e.Reason.Verbose(), "
  • "), "
  • ") + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(r) + statusMessage.WriteString("
    ") + + want := s.generateMessage(e) + + got := fmt.Sprintf(content, s.getStatusColor(e.Level), + provider.NotificationTitle(e), statusMessage.String(), e.Jenkins.Name, e.Phase) + + assert.Equal(t, want, got) + }) + + t.Run("with utf-8 characters", func(t *testing.T) { + crName := "ąśćńółżź" + crNamespace := "ąśćńółżź" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"ąśćńółżź"}, "ąśćńółżź") + + s := MailGun{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + var statusMessage strings.Builder + r := strings.TrimRight(strings.Join(e.Reason.Verbose(), "
  • "), "
  • ") + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(r) + statusMessage.WriteString("
    ") + + want := s.generateMessage(e) + + got := fmt.Sprintf(content, s.getStatusColor(e.Level), + provider.NotificationTitle(e), statusMessage.String(), e.Jenkins.Name, e.Phase) + + assert.Equal(t, want, got) + }) +} diff --git a/pkg/controller/jenkins/notifications/msteams.go b/pkg/controller/jenkins/notifications/msteams.go deleted file mode 100644 index ff6811b7..00000000 --- a/pkg/controller/jenkins/notifications/msteams.go +++ /dev/null @@ -1,133 +0,0 @@ -package notifications - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "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 a Microsoft MicrosoftTeams notification service -type Teams struct { - k8sClient k8sclient.Client -} - -// TeamsMessage is representation of json message structure -type TeamsMessage struct { - Type string `json:"@type"` - Context string `json:"@context"` - ThemeColor StatusColor `json:"themeColor"` - Title string `json:"title"` - Sections []TeamsSection `json:"sections"` - Summary string `json:"summary"` -} - -// TeamsSection is MS Teams message section -type TeamsSection struct { - Facts []TeamsFact `json:"facts"` - Text string `json:"text"` -} - -// TeamsFact is field where we can put content -type TeamsFact struct { - Name string `json:"name"` - Value string `json:"value"` -} - -func (t Teams) getStatusColor(logLevel v1alpha2.NotificationLogLevel) StatusColor { - switch logLevel { - case v1alpha2.NotificationLogLevelInfo: - return "439FE0" - case v1alpha2.NotificationLogLevelWarning: - return "E81123" - default: - return "C8C8C8" - } -} - -// Send is function for sending directly to API -func (t Teams) Send(event Event, config v1alpha2.Notification) error { - secret := &corev1.Secret{} - - selector := config.Teams.WebHookURLSecretKeySelector - - err := t.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) - if err != nil { - 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) - } - - tm := &TeamsMessage{ - Type: "MessageCard", - Context: "https://schema.org/extensions", - ThemeColor: t.getStatusColor(event.LogLevel), - Sections: []TeamsSection{ - { - Facts: []TeamsFact{ - { - Name: crNameFieldName, - Value: event.Jenkins.Name, - }, - { - Name: namespaceFieldName, - Value: event.Jenkins.Namespace, - }, - }, - Text: event.Message, - }, - }, - Summary: event.Message, - } - - tm.Title = notificationTitle(event) - - if config.Verbose { - message := event.Message - for _, msg := range event.MessagesVerbose { - message = message + "\n\n - " + msg - } - tm.Sections[0].Text += message - tm.Summary = message - } - - if event.Phase != PhaseUnknown { - tm.Sections[0].Facts = append(tm.Sections[0].Facts, TeamsFact{ - Name: phaseFieldName, - Value: string(event.Phase), - }) - } - - msg, err := json.Marshal(tm) - if err != nil { - return errors.WithStack(err) - } - - request, err := http.NewRequest("POST", secretValue, bytes.NewBuffer(msg)) - if err != nil { - return errors.WithStack(err) - } - - resp, err := client.Do(request) - if err != nil { - return errors.WithStack(err) - } - - if resp.StatusCode != http.StatusOK { - return errors.New(fmt.Sprintf("Invalid response from server: %s", resp.Status)) - } - defer func() { _ = resp.Body.Close() }() - - return nil -} diff --git a/pkg/controller/jenkins/notifications/msteams/msteams.go b/pkg/controller/jenkins/notifications/msteams/msteams.go new file mode 100644 index 00000000..d97fc50f --- /dev/null +++ b/pkg/controller/jenkins/notifications/msteams/msteams.go @@ -0,0 +1,147 @@ +package msteams + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + infoColor = "439FE0" + warningColor = "E81123" + defaultColor = "C8C8C8" +) + +// Teams is a Microsoft MicrosoftTeams notification service +type Teams struct { + httpClient http.Client + k8sClient k8sclient.Client + config v1alpha2.Notification +} + +// New returns instance of Teams +func New(k8sClient k8sclient.Client, config v1alpha2.Notification, httpClient http.Client) *Teams { + return &Teams{k8sClient: k8sClient, config: config, httpClient: httpClient} +} + +// Message is representation of json message structure +type Message struct { + Type string `json:"@type"` + Context string `json:"@context"` + ThemeColor event.StatusColor `json:"themeColor"` + Title string `json:"title"` + Sections []Section `json:"sections"` + Summary string `json:"summary"` +} + +// Section is MS Teams message section +type Section struct { + Facts []Fact `json:"facts"` + Text string `json:"text"` +} + +// Fact is field where we can put content +type Fact struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (t Teams) getStatusColor(logLevel v1alpha2.NotificationLevel) event.StatusColor { + switch logLevel { + case v1alpha2.NotificationLevelInfo: + return infoColor + case v1alpha2.NotificationLevelWarning: + return warningColor + default: + return defaultColor + } +} + +func (t Teams) generateMessage(e event.Event) Message { + var reason string + if t.config.Verbose { + reason = strings.Join(e.Reason.Verbose(), "\n\n - ") + } else { + reason = strings.Join(e.Reason.Short(), "\n\n - ") + } + + tm := Message{ + Title: provider.NotificationTitle(e), + Type: "MessageCard", + Context: "https://schema.org/extensions", + ThemeColor: t.getStatusColor(e.Level), + Sections: []Section{ + { + Facts: []Fact{ + { + Name: provider.CrNameFieldName, + Value: e.Jenkins.Name, + }, + { + Name: provider.NamespaceFieldName, + Value: e.Jenkins.Namespace, + }, + { + Name: provider.PhaseFieldName, + Value: string(e.Phase), + }, + }, + Text: reason, + }, + }, + Summary: reason, + } + + return tm +} + +// Send is function for sending directly to API +func (t Teams) Send(e event.Event) error { + secret := &corev1.Secret{} + + selector := t.config.Teams.WebHookURLSecretKeySelector + + err := t.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: e.Jenkins.Namespace}, secret) + if err != nil { + 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]", e.Jenkins.Namespace, selector.Name, selector.Key) + } + + msg, err := json.Marshal(t.generateMessage(e)) + if err != nil { + return errors.WithStack(err) + } + + request, err := http.NewRequest("POST", secretValue, bytes.NewBuffer(msg)) + if err != nil { + return errors.WithStack(err) + } + + resp, err := t.httpClient.Do(request) + if err != nil { + return errors.WithStack(err) + } + + if resp.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("Invalid response from server: %s", resp.Status)) + } + defer func() { _ = resp.Body.Close() }() + + return nil +} diff --git a/pkg/controller/jenkins/notifications/msteams/msteams_test.go b/pkg/controller/jenkins/notifications/msteams/msteams_test.go new file mode 100644 index 00000000..e052c340 --- /dev/null +++ b/pkg/controller/jenkins/notifications/msteams/msteams_test.go @@ -0,0 +1,287 @@ +package msteams + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var ( + testPhase = event.PhaseUser + testCrName = "test-cr" + testNamespace = "default" + testReason = reason.NewPodRestart( + reason.KubernetesSource, + []string{"test-reason-1"}, + []string{"test-verbose-1"}..., + ) + testLevel = v1alpha2.NotificationLevelWarning +) + +func TestTeams_Send(t *testing.T) { + fakeClient := fake.NewFakeClient() + testURLSelectorKeyName := "test-url-selector" + testSecretName := "test-secret" + + event := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: testCrName, + Namespace: testNamespace, + }, + }, + Phase: testPhase, + Level: testLevel, + Reason: testReason, + } + teams := Teams{k8sClient: fakeClient, config: v1alpha2.Notification{ + Teams: &v1alpha2.MicrosoftTeams{ + WebHookURLSecretKeySelector: v1alpha2.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: testSecretName, + }, + Key: testURLSelectorKeyName, + }, + }, + }} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var message Message + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&message) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, message.Title, provider.NotificationTitle(event)) + assert.Equal(t, message.ThemeColor, teams.getStatusColor(event.Level)) + + mainSection := message.Sections[0] + + reason := strings.Join(event.Reason.Short(), "\n\n - ") + + assert.Equal(t, mainSection.Text, reason) + + for _, fact := range mainSection.Facts { + switch fact.Name { + case provider.PhaseFieldName: + assert.Equal(t, fact.Value, string(event.Phase)) + case provider.CrNameFieldName: + assert.Equal(t, fact.Value, event.Jenkins.Name) + case provider.MessageFieldName: + assert.Equal(t, fact.Value, reason) + case provider.LevelFieldName: + assert.Equal(t, fact.Value, string(event.Level)) + case provider.NamespaceFieldName: + assert.Equal(t, fact.Value, event.Jenkins.Namespace) + default: + t.Errorf("Found unexpected '%+v' fact", fact) + } + } + })) + + defer server.Close() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSecretName, + Namespace: testNamespace, + }, + + Data: map[string][]byte{ + testURLSelectorKeyName: []byte(server.URL), + }, + } + + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + err = teams.Send(event) + assert.NoError(t, err) +} + +func TestGenerateMessages(t *testing.T) { + t.Run("happy", func(t *testing.T) { + crName := "test-jenkins" + crNamespace := "test-namespace" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"test-string"}, "test-verbose") + + s := Teams{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + msg := strings.Join(e.Reason.Verbose(), "\n\n - ") + + mainSection := message.Sections[0] + + crNameFact := mainSection.Facts[0] + namespaceFact := mainSection.Facts[1] + phaseFact := mainSection.Facts[2] + + assert.Equal(t, mainSection.Text, msg) + assert.Equal(t, crNameFact.Value, e.Jenkins.Name) + assert.Equal(t, namespaceFact.Value, e.Jenkins.Namespace) + assert.Equal(t, event.Phase(phaseFact.Value), e.Phase) + }) + + t.Run("with nils", func(t *testing.T) { + crName := "nil" + crNamespace := "nil" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"nil"}, "nil") + + s := Teams{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + msg := strings.Join(e.Reason.Verbose(), "\n\n - ") + + mainSection := message.Sections[0] + + crNameFact := mainSection.Facts[0] + namespaceFact := mainSection.Facts[1] + phaseFact := mainSection.Facts[2] + + assert.Equal(t, mainSection.Text, msg) + assert.Equal(t, crNameFact.Value, e.Jenkins.Name) + assert.Equal(t, namespaceFact.Value, e.Jenkins.Namespace) + assert.Equal(t, event.Phase(phaseFact.Value), e.Phase) + }) + + t.Run("with empty strings", func(t *testing.T) { + crName := "" + crNamespace := "" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{""}, "") + + s := Teams{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + msg := strings.Join(e.Reason.Verbose(), "\n\n - ") + + mainSection := message.Sections[0] + + crNameFact := mainSection.Facts[0] + namespaceFact := mainSection.Facts[1] + phaseFact := mainSection.Facts[2] + + assert.Equal(t, mainSection.Text, msg) + assert.Equal(t, crNameFact.Value, e.Jenkins.Name) + assert.Equal(t, namespaceFact.Value, e.Jenkins.Namespace) + assert.Equal(t, event.Phase(phaseFact.Value), e.Phase) + }) + + t.Run("with utf-8 characters", func(t *testing.T) { + crName := "ąśćńółżź" + crNamespace := "ąśćńółżź" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"ąśćńółżź"}, "ąśćńółżź") + s := Teams{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + msg := strings.Join(e.Reason.Verbose(), "\n\n - ") + + mainSection := message.Sections[0] + + crNameFact := mainSection.Facts[0] + namespaceFact := mainSection.Facts[1] + phaseFact := mainSection.Facts[2] + + assert.Equal(t, mainSection.Text, msg) + assert.Equal(t, crNameFact.Value, e.Jenkins.Name) + assert.Equal(t, namespaceFact.Value, e.Jenkins.Namespace) + assert.Equal(t, event.Phase(phaseFact.Value), e.Phase) + }) +} diff --git a/pkg/controller/jenkins/notifications/msteams_test.go b/pkg/controller/jenkins/notifications/msteams_test.go deleted file mode 100644 index 088f6928..00000000 --- a/pkg/controller/jenkins/notifications/msteams_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package notifications - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func TestTeams_Send(t *testing.T) { - fakeClient := fake.NewFakeClient() - testURLSelectorKeyName := "test-url-selector" - testSecretName := "test-secret" - - event := Event{ - Jenkins: v1alpha2.Jenkins{ - ObjectMeta: metav1.ObjectMeta{ - Name: testCrName, - Namespace: testNamespace, - }, - }, - Phase: testPhase, - Message: testMessage, - MessagesVerbose: testMessageVerbose, - LogLevel: testLoggingLevel, - } - 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, notificationTitle(event)) - assert.Equal(t, message.ThemeColor, teams.getStatusColor(event.LogLevel)) - - mainSection := message.Sections[0] - - assert.Equal(t, mainSection.Text, event.Message) - - for _, fact := range mainSection.Facts { - switch fact.Name { - case phaseFieldName: - assert.Equal(t, fact.Value, string(event.Phase)) - case crNameFieldName: - assert.Equal(t, fact.Value, event.Jenkins.Name) - case messageFieldName: - assert.Equal(t, fact.Value, event.Message) - case loggingLevelFieldName: - assert.Equal(t, fact.Value, string(event.LogLevel)) - case namespaceFieldName: - assert.Equal(t, fact.Value, event.Jenkins.Namespace) - default: - t.Errorf("Found unexpected '%+v' fact", fact) - } - } - })) - - defer server.Close() - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: testSecretName, - Namespace: testNamespace, - }, - - Data: map[string][]byte{ - testURLSelectorKeyName: []byte(server.URL), - }, - } - - err := fakeClient.Create(context.TODO(), secret) - assert.NoError(t, err) - - err = teams.Send(event, v1alpha2.Notification{ - Teams: &v1alpha2.MicrosoftTeams{ - WebHookURLSecretKeySelector: v1alpha2.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: testURLSelectorKeyName, - }, - }, - }) - assert.NoError(t, err) -} diff --git a/pkg/controller/jenkins/notifications/provider/provider.go b/pkg/controller/jenkins/notifications/provider/provider.go new file mode 100644 index 00000000..b509e0b7 --- /dev/null +++ b/pkg/controller/jenkins/notifications/provider/provider.go @@ -0,0 +1,40 @@ +package provider + +import ( + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" +) + +const ( + // InfoTitleText is info header of notification + InfoTitleText = "Jenkins Operator reconciliation info" + + // WarnTitleText is warning header of notification + WarnTitleText = "Jenkins Operator reconciliation warning" + + // MessageFieldName is field title for message content + MessageFieldName = "Message" + + // LevelFieldName is field title for level enum + LevelFieldName = "Level" + + // CrNameFieldName is field title for CR Name string + CrNameFieldName = "CR Name" + + // PhaseFieldName is field title for Phase enum + PhaseFieldName = "Phase" + + // NamespaceFieldName is field title for Namespace string + NamespaceFieldName = "Namespace" +) + +// NotificationTitle converts NotificationLevel enum to string +func NotificationTitle(event event.Event) string { + if event.Level == v1alpha2.NotificationLevelInfo { + return InfoTitleText + } else if event.Level == v1alpha2.NotificationLevelWarning { + return WarnTitleText + } else { + return "" + } +} diff --git a/pkg/controller/jenkins/notifications/reason/reason.go b/pkg/controller/jenkins/notifications/reason/reason.go new file mode 100644 index 00000000..f19a54e0 --- /dev/null +++ b/pkg/controller/jenkins/notifications/reason/reason.go @@ -0,0 +1,192 @@ +package reason + +import "fmt" + +const ( + // OperatorSource defines that notification concerns operator + OperatorSource Source = "operator" + // KubernetesSource defines that notification concerns kubernetes + KubernetesSource Source = "kubernetes" + // HumanSource defines that notification concerns human + HumanSource Source = "human" +) + +// Reason is interface that let us know why operator sent notification +type Reason interface { + Short() []string + Verbose() []string + HasMessages() bool +} + +// Undefined is base or untraceable reason +type Undefined struct { + source Source + short []string + verbose []string +} + +// PodRestart defines the reason why Jenkins master pod restarted +type PodRestart struct { + Undefined +} + +// PodCreation informs that pod is being created +type PodCreation struct { + Undefined +} + +// ReconcileLoopFailed defines the reason why the reconcile loop failed +type ReconcileLoopFailed struct { + Undefined +} + +// GroovyScriptExecutionFailed defines the reason why the groovy script execution failed +type GroovyScriptExecutionFailed struct { + Undefined +} + +// BaseConfigurationFailed defines the reason why base configuration phase failed +type BaseConfigurationFailed struct { + Undefined +} + +// BaseConfigurationComplete informs that base configuration is valid and complete +type BaseConfigurationComplete struct { + Undefined +} + +// UserConfigurationFailed defines the reason why user configuration phase failed +type UserConfigurationFailed struct { + Undefined +} + +// UserConfigurationComplete informs that user configuration is valid and complete +type UserConfigurationComplete struct { + Undefined +} + +// NewUndefined returns new instance of Undefined +func NewUndefined(source Source, short []string, verbose ...string) *Undefined { + return &Undefined{source: source, short: short, verbose: checkIfVerboseEmpty(short, verbose)} +} + +// NewPodRestart returns new instance of PodRestart +func NewPodRestart(source Source, short []string, verbose ...string) *PodRestart { + restartPodMessage := "Jenkins master pod restarted by:" + if len(short) == 1 { + short[0] = fmt.Sprintf("%s %s", restartPodMessage, short[0]) + } else if len(short) > 1 { + short = append([]string{restartPodMessage}, short...) + } + + return &PodRestart{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewPodCreation returns new instance of PodCreation +func NewPodCreation(source Source, short []string, verbose ...string) *PodCreation { + return &PodCreation{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewReconcileLoopFailed returns new instance of ReconcileLoopFailed +func NewReconcileLoopFailed(source Source, short []string, verbose ...string) *ReconcileLoopFailed { + return &ReconcileLoopFailed{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewGroovyScriptExecutionFailed returns new instance of GroovyScriptExecutionFailed +func NewGroovyScriptExecutionFailed(source Source, short []string, verbose ...string) *GroovyScriptExecutionFailed { + return &GroovyScriptExecutionFailed{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewBaseConfigurationFailed returns new instance of BaseConfigurationFailed +func NewBaseConfigurationFailed(source Source, short []string, verbose ...string) *BaseConfigurationFailed { + return &BaseConfigurationFailed{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewBaseConfigurationComplete returns new instance of BaseConfigurationComplete +func NewBaseConfigurationComplete(source Source, short []string, verbose ...string) *BaseConfigurationComplete { + return &BaseConfigurationComplete{ + Undefined{ + source: source, + short: short, + verbose: verbose, + }, + } +} + +// NewUserConfigurationFailed returns new instance of UserConfigurationFailed +func NewUserConfigurationFailed(source Source, short []string, verbose ...string) *UserConfigurationFailed { + return &UserConfigurationFailed{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// NewUserConfigurationComplete returns new instance of UserConfigurationComplete +func NewUserConfigurationComplete(source Source, short []string, verbose ...string) *UserConfigurationComplete { + return &UserConfigurationComplete{ + Undefined{ + source: source, + short: short, + verbose: checkIfVerboseEmpty(short, verbose), + }, + } +} + +// Source is enum type that informs us what triggered notification +type Source string + +// Short is list of reasons +func (p Undefined) Short() []string { + return p.short +} + +// Verbose is list of reasons with details +func (p Undefined) Verbose() []string { + return p.verbose +} + +// HasMessages checks if there is any message +func (p Undefined) HasMessages() bool { + return len(p.short) > 0 || len(p.verbose) > 0 +} + +func checkIfVerboseEmpty(short []string, verbose []string) []string { + if len(verbose) == 0 { + return short + } + + return verbose +} diff --git a/pkg/controller/jenkins/notifications/reason/reason_test.go b/pkg/controller/jenkins/notifications/reason/reason_test.go new file mode 100644 index 00000000..108e65a6 --- /dev/null +++ b/pkg/controller/jenkins/notifications/reason/reason_test.go @@ -0,0 +1,78 @@ +package reason + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCopyToVerboseIfNil(t *testing.T) { + t.Run("happy", func(t *testing.T) { + var verbose []string + short := []string{"test", "string"} + + assert.Equal(t, checkIfVerboseEmpty(short, verbose), short) + }) + + t.Run("check with invalid slice", func(t *testing.T) { + valid := []string{"valid", "string"} + invalid := []string{"invalid", "string"} + + assert.Equal(t, checkIfVerboseEmpty(invalid, valid), valid) + }) + + t.Run("check with two empty slices", func(t *testing.T) { + var short []string + var verbose []string + + assert.Equal(t, checkIfVerboseEmpty(short, verbose), verbose) + }) + + t.Run("with nils", func(t *testing.T) { + assert.Equal(t, checkIfVerboseEmpty(nil, nil), []string(nil)) + }) +} + +func TestUndefined_HasMessages(t *testing.T) { + t.Run("short full", func(t *testing.T) { + podRestart := NewUndefined(KubernetesSource, []string{"test", "another-test"}) + assert.True(t, podRestart.HasMessages()) + }) + + t.Run("verbose full", func(t *testing.T) { + podRestart := NewUndefined(KubernetesSource, []string{}, []string{"test", "another-test"}...) + assert.True(t, podRestart.HasMessages()) + }) + + t.Run("short empty", func(t *testing.T) { + podRestart := NewUndefined(KubernetesSource, []string{}) + assert.False(t, podRestart.HasMessages()) + }) + + t.Run("verbose and short full", func(t *testing.T) { + podRestart := NewUndefined(KubernetesSource, []string{"test", "another-test"}, []string{"test", "another-test"}...) + assert.True(t, podRestart.HasMessages()) + }) + + t.Run("verbose and short empty", func(t *testing.T) { + podRestart := NewUndefined(KubernetesSource, []string{}, []string{}...) + assert.False(t, podRestart.HasMessages()) + }) + +} + +func TestPodRestartPrepend(t *testing.T) { + t.Run("happy with one message", func(t *testing.T) { + res := "test-reason" + podRestart := NewPodRestart(KubernetesSource, []string{res}) + + assert.Equal(t, podRestart.short[0], fmt.Sprintf("Jenkins master pod restarted by: %s", res)) + }) + + t.Run("happy with multiple message", func(t *testing.T) { + podRestart := NewPodRestart(KubernetesSource, []string{"first-reason", "second-reason", "third-reason"}) + + assert.Equal(t, podRestart.short[0], "Jenkins master pod restarted by:") + }) +} diff --git a/pkg/controller/jenkins/notifications/sender.go b/pkg/controller/jenkins/notifications/sender.go index 7b299c25..45985960 100644 --- a/pkg/controller/jenkins/notifications/sender.go +++ b/pkg/controller/jenkins/notifications/sender.go @@ -3,130 +3,90 @@ package notifications import ( "fmt" "net/http" + "reflect" + "strings" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - "github.com/jenkinsci/kubernetes-operator/pkg/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/mailgun" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/msteams" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/slack" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/smtp" + k8sevent "github.com/jenkinsci/kubernetes-operator/pkg/event" "github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/pkg/errors" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) -const ( - infoTitleText = "Jenkins Operator reconciliation info" - warnTitleText = "Jenkins Operator reconciliation warning" - messageFieldName = "Message" - loggingLevelFieldName = "Logging Level" - crNameFieldName = "CR Name" - phaseFieldName = "Phase" - namespaceFieldName = "Namespace" -) - -const ( - // PhaseBase is core configuration of Jenkins provided by the Operator - PhaseBase Phase = "base" - - // PhaseUser is user-defined configuration of Jenkins - PhaseUser Phase = "user" - - // PhaseUnknown is untraceable type of configuration - PhaseUnknown Phase = "unknown" -) - -var ( - testPhase = PhaseUser - testCrName = "test-cr" - testNamespace = "default" - testMessage = "test-message" - testMessageVerbose = []string{"detail-test-message"} - testLoggingLevel = v1alpha2.NotificationLogLevelWarning - - client = http.Client{} -) - -// Phase defines the type of configuration -type Phase string - -// 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 - Phase Phase - LogLevel v1alpha2.NotificationLogLevel - Message string - MessagesVerbose []string -} - -type service interface { - Send(event Event, notificationConfig v1alpha2.Notification) error +// Provider is the communication service handler +type Provider interface { + Send(event event.Event) error } // Listen listens for incoming events and send it as notifications -func Listen(events chan Event, k8sEvent event.Recorder, k8sClient k8sclient.Client) { - for evt := range events { - logger := log.Log.WithValues("cr", evt.Jenkins.Name) - for _, notificationConfig := range evt.Jenkins.Spec.Notifications { +func Listen(events chan event.Event, k8sEvent k8sevent.Recorder, k8sClient k8sclient.Client) { + httpClient := http.Client{} + for e := range events { + logger := log.Log.WithValues("cr", e.Jenkins.Name) + + if !e.Reason.HasMessages() { + logger.V(log.VWarn).Info("Reason has no messages, this should not happen") + continue // skip empty messages + } + + k8sEvent.Emit(&e.Jenkins, + eventLevelToKubernetesEventType(e.Level), + k8sevent.Reason(reflect.TypeOf(e.Reason).Name()), + strings.Join(e.Reason.Short(), "; "), + ) + + for _, notificationConfig := range e.Jenkins.Spec.Notifications { var err error - var svc service + var provider Provider if notificationConfig.Slack != nil { - svc = Slack{k8sClient: k8sClient} + provider = slack.New(k8sClient, notificationConfig, httpClient) } else if notificationConfig.Teams != nil { - svc = Teams{k8sClient: k8sClient} + provider = msteams.New(k8sClient, notificationConfig, httpClient) } else if notificationConfig.Mailgun != nil { - svc = MailGun{k8sClient: k8sClient} + provider = mailgun.New(k8sClient, notificationConfig) } else if notificationConfig.SMTP != nil { - svc = SMTP{k8sClient: k8sClient} + provider = smtp.New(k8sClient, notificationConfig) } else { logger.V(log.VWarn).Info(fmt.Sprintf("Unknown notification service `%+v`", notificationConfig)) continue } - go func(notificationConfig v1alpha2.Notification) { - err = notify(svc, evt, notificationConfig) + isInfoEvent := e.Level == v1alpha2.NotificationLevelInfo + wantsWarning := notificationConfig.LoggingLevel == v1alpha2.NotificationLevelWarning + if isInfoEvent && wantsWarning { + continue // skip the event + } + go func(notificationConfig v1alpha2.Notification) { + err = provider.Send(e) if err != nil { + wrapped := errors.WithMessage(err, + fmt.Sprintf("failed to send notification '%s'", notificationConfig.Name)) if log.Debug { - logger.Error(nil, fmt.Sprintf("%+v", errors.WithMessage(err, fmt.Sprintf("failed to send notification '%s'", notificationConfig.Name)))) + logger.Error(nil, fmt.Sprintf("%+v", wrapped)) } else { - logger.Error(nil, fmt.Sprintf("%s", errors.WithMessage(err, fmt.Sprintf("failed to send notification '%s'", notificationConfig.Name)))) + logger.Error(nil, fmt.Sprintf("%s", wrapped)) } } }(notificationConfig) } - k8sEvent.Emit(&evt.Jenkins, logLevelEventType(evt.LogLevel), "NotificationSent", evt.Message) } } -func logLevelEventType(level v1alpha2.NotificationLogLevel) event.Type { +func eventLevelToKubernetesEventType(level v1alpha2.NotificationLevel) k8sevent.Type { switch level { - case v1alpha2.NotificationLogLevelWarning: - return event.TypeWarning - case v1alpha2.NotificationLogLevelInfo: - return event.TypeNormal + case v1alpha2.NotificationLevelWarning: + return k8sevent.TypeWarning + case v1alpha2.NotificationLevelInfo: + return k8sevent.TypeNormal default: - return event.TypeNormal - } -} - -func notify(svc service, event Event, manifest v1alpha2.Notification) error { - if event.LogLevel == v1alpha2.NotificationLogLevelInfo && manifest.LoggingLevel == v1alpha2.NotificationLogLevelWarning { - return nil - } - return svc.Send(event, manifest) -} - -func notificationTitle(event Event) string { - if event.LogLevel == v1alpha2.NotificationLogLevelInfo { - return infoTitleText - } else if event.LogLevel == v1alpha2.NotificationLogLevelWarning { - return warnTitleText - } else { - return "" + return k8sevent.TypeNormal } } diff --git a/pkg/controller/jenkins/notifications/slack.go b/pkg/controller/jenkins/notifications/slack.go deleted file mode 100644 index 6468e528..00000000 --- a/pkg/controller/jenkins/notifications/slack.go +++ /dev/null @@ -1,136 +0,0 @@ -package notifications - -import ( - "bytes" - "context" - "encoding/json" - "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" -) - -// Slack is a Slack notification service -type Slack struct { - k8sClient k8sclient.Client -} - -// SlackMessage is representation of json message -type SlackMessage struct { - Text string `json:"text"` - Attachments []SlackAttachment `json:"attachments"` -} - -// SlackAttachment is representation of json attachment -type SlackAttachment struct { - Fallback string `json:"fallback"` - Color StatusColor `json:"color"` - Pretext string `json:"pretext"` - Title string `json:"title"` - Text string `json:"text"` - Fields []SlackField `json:"fields"` - Footer string `json:"footer"` -} - -// SlackField is representation of json field. -type SlackField struct { - Title string `json:"title"` - Value string `json:"value"` - Short bool `json:"short"` -} - -func (s Slack) getStatusColor(logLevel v1alpha2.NotificationLogLevel) StatusColor { - switch logLevel { - case v1alpha2.NotificationLogLevelInfo: - return "#439FE0" - case v1alpha2.NotificationLogLevelWarning: - 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.WebHookURLSecretKeySelector - - err := s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: event.Jenkins.Namespace}, secret) - if err != nil { - return err - } - - sm := &SlackMessage{ - Attachments: []SlackAttachment{ - { - Fallback: "", - Color: s.getStatusColor(event.LogLevel), - Fields: []SlackField{ - { - Title: "", - Value: event.Message, - Short: false, - }, - { - Title: namespaceFieldName, - Value: event.Jenkins.Namespace, - Short: true, - }, - { - Title: crNameFieldName, - Value: event.Jenkins.Name, - Short: true, - }, - }, - }, - }, - } - - mainAttachment := &sm.Attachments[0] - - mainAttachment.Title = notificationTitle(event) - - if config.Verbose { - // TODO: or for title == message - message := event.Message - for _, msg := range event.MessagesVerbose { - message = message + "\n - " + msg - } - mainAttachment.Fields[0].Value = message - } - - if event.Phase != PhaseUnknown { - mainAttachment.Fields = append(mainAttachment.Fields, SlackField{ - Title: phaseFieldName, - Value: string(event.Phase), - Short: true, - }) - } - - slackMessage, err := json.Marshal(sm) - if err != nil { - return err - } - - secretValue := string(secret.Data[selector.Key]) - if secretValue == "" { - return errors.Errorf("Slack WebHook URL is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, selector.Name, selector.Key) - } - - request, err := http.NewRequest("POST", secretValue, bytes.NewBuffer(slackMessage)) - if err != nil { - return err - } - - resp, err := client.Do(request) - if err != nil { - return err - } - - defer func() { _ = resp.Body.Close() }() - return nil -} diff --git a/pkg/controller/jenkins/notifications/slack/slack.go b/pkg/controller/jenkins/notifications/slack/slack.go new file mode 100644 index 00000000..797af921 --- /dev/null +++ b/pkg/controller/jenkins/notifications/slack/slack.go @@ -0,0 +1,152 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + infoColor = "#439FE0" + warningColor = "danger" + defaultColor = "#c8c8c8" +) + +// Slack is a Slack notification service +type Slack struct { + httpClient http.Client + k8sClient k8sclient.Client + config v1alpha2.Notification +} + +// New returns instance of Slack +func New(k8sClient k8sclient.Client, config v1alpha2.Notification, httpClient http.Client) *Slack { + return &Slack{k8sClient: k8sClient, config: config, httpClient: httpClient} +} + +// Message is representation of json message +type Message struct { + Text string `json:"text"` + Attachments []Attachment `json:"attachments"` +} + +// Attachment is representation of json attachment +type Attachment struct { + Fallback string `json:"fallback"` + Color event.StatusColor `json:"color"` + Pretext string `json:"pretext"` + Title string `json:"title"` + Text string `json:"text"` + Fields []Field `json:"fields"` + Footer string `json:"footer"` +} + +// Field is representation of json field. +type Field struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +func (s Slack) getStatusColor(logLevel v1alpha2.NotificationLevel) event.StatusColor { + switch logLevel { + case v1alpha2.NotificationLevelInfo: + return infoColor + case v1alpha2.NotificationLevelWarning: + return warningColor + default: + return defaultColor + } +} + +func (s Slack) generateMessage(e event.Event) Message { + var messageStringBuilder strings.Builder + if s.config.Verbose { + for _, msg := range e.Reason.Verbose() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + } else { + for _, msg := range e.Reason.Short() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + } + + sm := Message{ + Attachments: []Attachment{ + { + Title: provider.NotificationTitle(e), + Fallback: "", + Color: s.getStatusColor(e.Level), + Fields: []Field{ + { + Title: "", + Value: messageStringBuilder.String(), + Short: false, + }, + { + Title: provider.NamespaceFieldName, + Value: e.Jenkins.Namespace, + Short: true, + }, + { + Title: provider.CrNameFieldName, + Value: e.Jenkins.Name, + Short: true, + }, + { + Title: provider.PhaseFieldName, + Value: string(e.Phase), + Short: true, + }, + }, + }, + }, + } + + return sm +} + +// Send is function for sending directly to API +func (s Slack) Send(e event.Event) error { + secret := &corev1.Secret{} + selector := s.config.Slack.WebHookURLSecretKeySelector + + err := s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: selector.Name, Namespace: e.Jenkins.Namespace}, secret) + if err != nil { + return err + } + + slackMessage, err := json.Marshal(s.generateMessage(e)) + if err != nil { + return err + } + + secretValue := string(secret.Data[selector.Key]) + if secretValue == "" { + return errors.Errorf("Slack WebHook URL is empty in secret '%s/%s[%s]", e.Jenkins.Namespace, selector.Name, selector.Key) + } + + request, err := http.NewRequest("POST", secretValue, bytes.NewBuffer(slackMessage)) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(request) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + return nil +} diff --git a/pkg/controller/jenkins/notifications/slack/slack_test.go b/pkg/controller/jenkins/notifications/slack/slack_test.go new file mode 100644 index 00000000..2fd1322f --- /dev/null +++ b/pkg/controller/jenkins/notifications/slack/slack_test.go @@ -0,0 +1,303 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var ( + testPhase = event.PhaseUser + testCrName = "test-cr" + testNamespace = "default" + testReason = reason.NewPodRestart( + reason.KubernetesSource, + []string{"test-reason-1"}, + []string{"test-verbose-1"}..., + ) + testLevel = v1alpha2.NotificationLevelWarning +) + +func TestSlack_Send(t *testing.T) { + fakeClient := fake.NewFakeClient() + testURLSelectorKeyName := "test-url-selector" + testSecretName := "test-secret" + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: testCrName, + Namespace: testNamespace, + }, + }, + Phase: testPhase, + Level: testLevel, + Reason: testReason, + } + + slack := Slack{k8sClient: fakeClient, config: v1alpha2.Notification{ + Slack: &v1alpha2.Slack{ + WebHookURLSecretKeySelector: v1alpha2.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: testSecretName, + }, + Key: testURLSelectorKeyName, + }, + }, + }} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var message Message + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&message) + + if err != nil { + t.Fatal(err) + } + + mainAttachment := message.Attachments[0] + + assert.Equal(t, mainAttachment.Title, provider.NotificationTitle(e)) + for _, field := range mainAttachment.Fields { + switch field.Title { + case provider.PhaseFieldName: + assert.Equal(t, field.Value, string(e.Phase)) + case provider.CrNameFieldName: + assert.Equal(t, field.Value, e.Jenkins.Name) + case "": + message := "" + for _, msg := range e.Reason.Short() { + message = message + "\n - " + msg + "\n" + } + assert.Equal(t, field.Value, message) + case provider.LevelFieldName: + assert.Equal(t, field.Value, string(e.Level)) + case provider.NamespaceFieldName: + assert.Equal(t, field.Value, e.Jenkins.Namespace) + default: + t.Errorf("Unexpected field %+v", field) + } + } + + assert.Equal(t, mainAttachment.Footer, "") + assert.Equal(t, mainAttachment.Color, slack.getStatusColor(e.Level)) + })) + + defer server.Close() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSecretName, + Namespace: testNamespace, + }, + + Data: map[string][]byte{ + testURLSelectorKeyName: []byte(server.URL), + }, + } + + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + err = slack.Send(e) + assert.NoError(t, err) +} + +func TestGenerateMessage(t *testing.T) { + t.Run("happy", func(t *testing.T) { + crName := "test-jenkins" + crNamespace := "test-namespace" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"test-string"}, "test-verbose") + + s := Slack{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + var messageStringBuilder strings.Builder + for _, msg := range e.Reason.Verbose() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + + mainAttachment := message.Attachments[0] + messageField := mainAttachment.Fields[0] + namespaceField := mainAttachment.Fields[1] + crNameField := mainAttachment.Fields[2] + phaseField := mainAttachment.Fields[3] + + assert.Equal(t, messageField.Value, messageStringBuilder.String()) + assert.Equal(t, namespaceField.Value, e.Jenkins.Namespace) + assert.Equal(t, crNameField.Value, e.Jenkins.Name) + assert.Equal(t, event.Phase(phaseField.Value), e.Phase) + }) + + t.Run("with nils", func(t *testing.T) { + crName := "nil" + crNamespace := "nil" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"nil"}, "nil") + + s := Slack{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + var messageStringBuilder strings.Builder + for _, msg := range e.Reason.Verbose() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + + mainAttachment := message.Attachments[0] + messageField := mainAttachment.Fields[0] + namespaceField := mainAttachment.Fields[1] + crNameField := mainAttachment.Fields[2] + phaseField := mainAttachment.Fields[3] + + assert.Equal(t, messageField.Value, messageStringBuilder.String()) + assert.Equal(t, namespaceField.Value, e.Jenkins.Namespace) + assert.Equal(t, crNameField.Value, e.Jenkins.Name) + assert.Equal(t, event.Phase(phaseField.Value), e.Phase) + }) + + t.Run("with empty strings", func(t *testing.T) { + crName := "" + crNamespace := "" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{""}, "") + + s := Slack{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + var messageStringBuilder strings.Builder + for _, msg := range e.Reason.Verbose() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + + mainAttachment := message.Attachments[0] + messageField := mainAttachment.Fields[0] + namespaceField := mainAttachment.Fields[1] + crNameField := mainAttachment.Fields[2] + phaseField := mainAttachment.Fields[3] + + assert.Equal(t, messageField.Value, messageStringBuilder.String()) + assert.Equal(t, namespaceField.Value, e.Jenkins.Namespace) + assert.Equal(t, crNameField.Value, e.Jenkins.Name) + assert.Equal(t, event.Phase(phaseField.Value), e.Phase) + }) + + t.Run("with utf-8 characters", func(t *testing.T) { + crName := "ąśćńółżź" + crNamespace := "ąśćńółżź" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"ąśćńółżź"}, "ąśćńółżź") + + s := Slack{ + httpClient: http.Client{}, + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + Verbose: true, + }, + } + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + Namespace: crNamespace, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + + message := s.generateMessage(e) + + var messageStringBuilder strings.Builder + for _, msg := range e.Reason.Verbose() { + messageStringBuilder.WriteString("\n - " + msg + "\n") + } + + mainAttachment := message.Attachments[0] + messageField := mainAttachment.Fields[0] + namespaceField := mainAttachment.Fields[1] + crNameField := mainAttachment.Fields[2] + phaseField := mainAttachment.Fields[3] + + assert.Equal(t, messageField.Value, messageStringBuilder.String()) + assert.Equal(t, namespaceField.Value, e.Jenkins.Namespace) + assert.Equal(t, crNameField.Value, e.Jenkins.Name) + assert.Equal(t, event.Phase(phaseField.Value), e.Phase) + }) +} diff --git a/pkg/controller/jenkins/notifications/slack_test.go b/pkg/controller/jenkins/notifications/slack_test.go deleted file mode 100644 index fa5b6c94..00000000 --- a/pkg/controller/jenkins/notifications/slack_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package notifications - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func TestSlack_Send(t *testing.T) { - fakeClient := fake.NewFakeClient() - testURLSelectorKeyName := "test-url-selector" - testSecretName := "test-secret" - - event := Event{ - Jenkins: v1alpha2.Jenkins{ - ObjectMeta: metav1.ObjectMeta{ - Name: testCrName, - Namespace: testNamespace, - }, - }, - Phase: testPhase, - Message: testMessage, - MessagesVerbose: testMessageVerbose, - LogLevel: testLoggingLevel, - } - slack := Slack{k8sClient: fakeClient} - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var message SlackMessage - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&message) - - if err != nil { - t.Fatal(err) - } - - mainAttachment := message.Attachments[0] - - assert.Equal(t, mainAttachment.Title, notificationTitle(event)) - for _, field := range mainAttachment.Fields { - switch field.Title { - case phaseFieldName: - assert.Equal(t, field.Value, string(event.Phase)) - case crNameFieldName: - assert.Equal(t, field.Value, event.Jenkins.Name) - case "": - assert.Equal(t, field.Value, event.Message) - case loggingLevelFieldName: - assert.Equal(t, field.Value, string(event.LogLevel)) - case namespaceFieldName: - assert.Equal(t, field.Value, event.Jenkins.Namespace) - default: - t.Errorf("Unexpected field %+v", field) - } - } - - assert.Equal(t, mainAttachment.Footer, "") - assert.Equal(t, mainAttachment.Color, slack.getStatusColor(event.LogLevel)) - })) - - defer server.Close() - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: testSecretName, - Namespace: testNamespace, - }, - - Data: map[string][]byte{ - testURLSelectorKeyName: []byte(server.URL), - }, - } - - err := fakeClient.Create(context.TODO(), secret) - assert.NoError(t, err) - - err = slack.Send(event, v1alpha2.Notification{ - Slack: &v1alpha2.Slack{ - WebHookURLSecretKeySelector: v1alpha2.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: testURLSelectorKeyName, - }, - }, - }) - assert.NoError(t, err) -} diff --git a/pkg/controller/jenkins/notifications/smtp.go b/pkg/controller/jenkins/notifications/smtp.go deleted file mode 100644 index 02f78c3b..00000000 --- a/pkg/controller/jenkins/notifications/smtp.go +++ /dev/null @@ -1,94 +0,0 @@ -package notifications - -import ( - "context" - "crypto/tls" - "fmt" - - "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" - - "github.com/pkg/errors" - "gopkg.in/gomail.v2" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - k8sclient "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - mailSubject = "Jenkins Operator Notification" -) - -// SMTP is Simple Mail Transport Protocol used for sending emails -type SMTP struct { - k8sClient k8sclient.Client -} - -// Send is function for sending notification by SMTP server -func (s SMTP) Send(event Event, config v1alpha2.Notification) error { - usernameSecret := &corev1.Secret{} - passwordSecret := &corev1.Secret{} - - usernameSelector := config.SMTP.UsernameSecretKeySelector - passwordSelector := config.SMTP.PasswordSecretKeySelector - - err := s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: usernameSelector.Name, Namespace: event.Jenkins.Namespace}, usernameSecret) - if err != nil { - return err - } - - err = s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: passwordSelector.Name, Namespace: event.Jenkins.Namespace}, passwordSecret) - if err != nil { - return err - } - - usernameSecretValue := string(usernameSecret.Data[usernameSelector.Key]) - if usernameSecretValue == "" { - return errors.Errorf("SMTP username is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, usernameSelector.Name, usernameSelector.Key) - } - - passwordSecretValue := string(passwordSecret.Data[passwordSelector.Key]) - if passwordSecretValue == "" { - return errors.Errorf("SMTP password is empty in secret '%s/%s[%s]", event.Jenkins.Namespace, passwordSelector.Name, passwordSelector.Key) - } - - mailer := gomail.NewDialer(config.SMTP.Server, config.SMTP.Port, usernameSecretValue, passwordSecretValue) - mailer.TLSConfig = &tls.Config{InsecureSkipVerify: config.SMTP.TLSInsecureSkipVerify} - - var statusMessage string - - if config.Verbose { - message := event.Message + "
      " - for _, msg := range event.MessagesVerbose { - message = message + "
    • " + msg + "
    • " - } - message = message + "
    " - statusMessage = message - } else { - statusMessage = event.Message - } - - htmlMessage := fmt.Sprintf(content, s.getStatusColor(event.LogLevel), notificationTitle(event), statusMessage, event.Jenkins.Name, event.Phase) - message := gomail.NewMessage() - - message.SetHeader("From", config.SMTP.From) - message.SetHeader("To", config.SMTP.To) - message.SetHeader("Subject", mailSubject) - message.SetBody("text/html", htmlMessage) - - if err := mailer.DialAndSend(message); err != nil { - return err - } - - return nil -} - -func (s SMTP) getStatusColor(logLevel v1alpha2.NotificationLogLevel) StatusColor { - switch logLevel { - case v1alpha2.NotificationLogLevelInfo: - return "blue" - case v1alpha2.NotificationLogLevelWarning: - return "red" - default: - return "gray" - } -} diff --git a/pkg/controller/jenkins/notifications/smtp/smtp.go b/pkg/controller/jenkins/notifications/smtp/smtp.go new file mode 100644 index 00000000..a7d94b45 --- /dev/null +++ b/pkg/controller/jenkins/notifications/smtp/smtp.go @@ -0,0 +1,134 @@ +package smtp + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/provider" + + "github.com/pkg/errors" + "gopkg.in/gomail.v2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + mailSubject = "Jenkins Operator Notification" + + infoColor = "blue" + warningColor = "red" + defaultColor = "gray" + + content = ` + + + + +

    %s

    +

    %s

    + + + + + + + + + +
    CR name:%s
    Phase:%s
    +
    Powered by Jenkins Operator <3
    + +` +) + +// SMTP is Simple Mail Transport Protocol used for sending emails +type SMTP struct { + k8sClient k8sclient.Client + config v1alpha2.Notification +} + +// New returns instance of SMTP +func New(k8sClient k8sclient.Client, config v1alpha2.Notification) *SMTP { + return &SMTP{k8sClient: k8sClient, config: config} +} + +func (s SMTP) generateMessage(e event.Event) *gomail.Message { + var statusMessage strings.Builder + var reasons string + + if s.config.Verbose { + reasons = strings.TrimRight(strings.Join(e.Reason.Verbose(), "
  • "), "
  • ") + } else { + reasons = strings.TrimRight(strings.Join(e.Reason.Short(), "
  • "), "
  • ") + } + + statusMessage.WriteString("
    • ") + statusMessage.WriteString(reasons) + statusMessage.WriteString("
    ") + + htmlMessage := fmt.Sprintf(content, s.getStatusColor(e.Level), provider.NotificationTitle(e), statusMessage.String(), e.Jenkins.Name, e.Phase) + message := gomail.NewMessage() + + message.SetHeader("From", s.config.SMTP.From) + message.SetHeader("To", s.config.SMTP.To) + message.SetHeader("Subject", mailSubject) + message.SetBody("text/html", htmlMessage) + + return message +} + +// Send is function for sending notification by SMTP server +func (s SMTP) Send(e event.Event) error { + usernameSecret := &corev1.Secret{} + passwordSecret := &corev1.Secret{} + + usernameSelector := s.config.SMTP.UsernameSecretKeySelector + passwordSelector := s.config.SMTP.PasswordSecretKeySelector + + err := s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: usernameSelector.Name, Namespace: e.Jenkins.Namespace}, usernameSecret) + if err != nil { + return err + } + + err = s.k8sClient.Get(context.TODO(), types.NamespacedName{Name: passwordSelector.Name, Namespace: e.Jenkins.Namespace}, passwordSecret) + if err != nil { + return err + } + + usernameSecretValue := string(usernameSecret.Data[usernameSelector.Key]) + if usernameSecretValue == "" { + return errors.Errorf("SMTP username is empty in secret '%s/%s[%s]", e.Jenkins.Namespace, usernameSelector.Name, usernameSelector.Key) + } + + passwordSecretValue := string(passwordSecret.Data[passwordSelector.Key]) + if passwordSecretValue == "" { + return errors.Errorf("SMTP password is empty in secret '%s/%s[%s]", e.Jenkins.Namespace, passwordSelector.Name, passwordSelector.Key) + } + + mailer := gomail.NewDialer(s.config.SMTP.Server, s.config.SMTP.Port, usernameSecretValue, passwordSecretValue) + mailer.TLSConfig = &tls.Config{InsecureSkipVerify: s.config.SMTP.TLSInsecureSkipVerify} + + message := s.generateMessage(e) + if err := mailer.DialAndSend(message); err != nil { + return err + } + + return nil +} + +func (s SMTP) getStatusColor(logLevel v1alpha2.NotificationLevel) event.StatusColor { + switch logLevel { + case v1alpha2.NotificationLevelInfo: + return infoColor + case v1alpha2.NotificationLevelWarning: + return warningColor + default: + return defaultColor + } +} diff --git a/pkg/controller/jenkins/notifications/smtp_test.go b/pkg/controller/jenkins/notifications/smtp/smtp_test.go similarity index 63% rename from pkg/controller/jenkins/notifications/smtp_test.go rename to pkg/controller/jenkins/notifications/smtp/smtp_test.go index 5267af19..aa9906a0 100644 --- a/pkg/controller/jenkins/notifications/smtp_test.go +++ b/pkg/controller/jenkins/notifications/smtp/smtp_test.go @@ -1,4 +1,4 @@ -package notifications +package smtp import ( "context" @@ -13,6 +13,8 @@ import ( "time" "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/event" + "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason" "github.com/emersion/go-smtp" "github.com/stretchr/testify/assert" @@ -37,8 +39,20 @@ const ( subjectHeader = "Subject" ) +var ( + testPhase = event.PhaseUser + testCrName = "test-cr" + testNamespace = "default" + testReason = reason.NewPodRestart( + reason.KubernetesSource, + []string{"test-reason-1"}, + []string{"test-verbose-1"}..., + ) + testLevel = v1alpha2.NotificationLevelWarning +) + type testServer struct { - event Event + event event.Event } // Login handles a login command with username and password. @@ -56,7 +70,7 @@ func (bkd *testServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session // A Session is returned after successful login. type testSession struct { - event Event + event event.Event } func (s *testSession) Mail(from string) error { @@ -111,17 +125,16 @@ func (s *testSession) Logout() error { } func TestSMTP_Send(t *testing.T) { - event := Event{ + event := event.Event{ Jenkins: v1alpha2.Jenkins{ ObjectMeta: metav1.ObjectMeta{ Name: testCrName, Namespace: testNamespace, }, }, - Phase: testPhase, - Message: testMessage, - MessagesVerbose: testMessageVerbose, - LogLevel: testLoggingLevel, + Phase: testPhase, + Level: testLevel, + Reason: testReason, } fakeClient := fake.NewFakeClient() @@ -129,7 +142,27 @@ func TestSMTP_Send(t *testing.T) { testPasswordSelectorKeyName := "test-password-selector" testSecretName := "test-secret" - smtpClient := SMTP{k8sClient: fakeClient} + smtpClient := SMTP{k8sClient: fakeClient, config: v1alpha2.Notification{ + SMTP: &v1alpha2.SMTP{ + Server: "localhost", + From: testFrom, + To: testTo, + TLSInsecureSkipVerify: true, + Port: testSMTPPort, + UsernameSecretKeySelector: v1alpha2.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: testSecretName, + }, + Key: testUsernameSelectorKeyName, + }, + PasswordSecretKeySelector: v1alpha2.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: testSecretName, + }, + Key: testPasswordSelectorKeyName, + }, + }, + }} ts := &testServer{event: event} @@ -169,27 +202,108 @@ func TestSMTP_Send(t *testing.T) { assert.NoError(t, err) }() - err = smtpClient.Send(event, v1alpha2.Notification{ - SMTP: &v1alpha2.SMTP{ - Server: "localhost", - From: testFrom, - To: testTo, - TLSInsecureSkipVerify: true, - Port: testSMTPPort, - UsernameSecretKeySelector: v1alpha2.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: testUsernameSelectorKeyName, - }, - PasswordSecretKeySelector: v1alpha2.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: testPasswordSelectorKeyName, - }, - }, - }) + err = smtpClient.Send(event) assert.NoError(t, err) } + +func TestGenerateMessage(t *testing.T) { + t.Run("happy", func(t *testing.T) { + crName := "test-jenkins" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"test"}, []string{"test-verbose"}...) + + from := "from@jenkins.local" + to := "to@jenkins.local" + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + s := SMTP{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + LoggingLevel: level, + SMTP: &v1alpha2.SMTP{ + From: from, + To: to, + }, + }, + } + message := s.generateMessage(e) + assert.NotNil(t, message) + }) + + t.Run("with nils", func(t *testing.T) { + crName := "nil" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{"nil"}, []string{"nil"}...) + + from := "nil" + to := "nil" + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + s := SMTP{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + LoggingLevel: level, + SMTP: &v1alpha2.SMTP{ + From: from, + To: to, + }, + }, + } + message := s.generateMessage(e) + assert.NotNil(t, message) + }) + + t.Run("with empty strings", func(t *testing.T) { + crName := "" + phase := event.PhaseBase + level := v1alpha2.NotificationLevelInfo + res := reason.NewUndefined(reason.KubernetesSource, []string{""}, []string{""}...) + + from := "" + to := "" + + e := event.Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: crName, + }, + }, + Phase: phase, + Level: level, + Reason: res, + } + s := SMTP{ + k8sClient: fake.NewFakeClient(), + config: v1alpha2.Notification{ + LoggingLevel: level, + SMTP: &v1alpha2.SMTP{ + From: from, + To: to, + }, + }, + } + message := s.generateMessage(e) + assert.NotNil(t, message) + }) +}