parent
8182858b75
commit
6d32ee71e4
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins"
|
"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/constants"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications"
|
"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/event"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
||||||
"github.com/jenkinsci/kubernetes-operator/version"
|
"github.com/jenkinsci/kubernetes-operator/version"
|
||||||
|
|
@ -119,7 +120,7 @@ func main() {
|
||||||
fatal(errors.Wrap(err, "failed to create Kubernetes client set"), *debug)
|
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())
|
go notifications.Listen(c, events, mgr.GetClient())
|
||||||
|
|
||||||
// setup Jenkins controller
|
// setup Jenkins controller
|
||||||
|
|
|
||||||
|
|
@ -54,26 +54,26 @@ type JenkinsSpec struct {
|
||||||
ConfigurationAsCode ConfigurationAsCode `json:"configurationAsCode,omitempty"`
|
ConfigurationAsCode ConfigurationAsCode `json:"configurationAsCode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationLogLevel defines logging level of Notification
|
// NotificationLevel defines the level of a Notification
|
||||||
type NotificationLogLevel string
|
type NotificationLevel string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// NotificationLogLevelWarning - Only Warnings
|
// NotificationLevelWarning - Only Warnings
|
||||||
NotificationLogLevelWarning NotificationLogLevel = "warning"
|
NotificationLevelWarning NotificationLevel = "warning"
|
||||||
|
|
||||||
// NotificationLogLevelInfo - Only info
|
// NotificationLevelInfo - Only info
|
||||||
NotificationLogLevelInfo NotificationLogLevel = "info"
|
NotificationLevelInfo NotificationLevel = "info"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification is a service configuration used to send notifications about Jenkins status
|
// Notification is a service configuration used to send notifications about Jenkins status
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
LoggingLevel NotificationLogLevel `json:"loggingLevel"`
|
LoggingLevel NotificationLevel `json:"level"`
|
||||||
Verbose bool `json:"verbose"`
|
Verbose bool `json:"verbose"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slack *Slack `json:"slack,omitempty"`
|
Slack *Slack `json:"slack,omitempty"`
|
||||||
Teams *MicrosoftTeams `json:"teams,omitempty"`
|
Teams *MicrosoftTeams `json:"teams,omitempty"`
|
||||||
Mailgun *Mailgun `json:"mailgun,omitempty"`
|
Mailgun *Mailgun `json:"mailgun,omitempty"`
|
||||||
SMTP *SMTP `json:"smtp,omitempty"`
|
SMTP *SMTP `json:"smtp,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slack is handler for Slack notification channel
|
// Slack is handler for Slack notification channel
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import (
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/backuprestore"
|
"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/configuration/base/resources"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy"
|
"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/controller/jenkins/plugins"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
||||||
"github.com/jenkinsci/kubernetes-operator/version"
|
"github.com/jenkinsci/kubernetes-operator/version"
|
||||||
|
|
@ -106,8 +107,14 @@ func (r *ReconcileJenkinsBaseConfiguration) Reconcile() (reconcile.Result, jenki
|
||||||
return reconcile.Result{}, nil, err
|
return reconcile.Result{}, nil, err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
r.logger.Info("Some plugins have changed, restarting Jenkins")
|
message := "Some plugins have changed, restarting Jenkins"
|
||||||
return reconcile.Result{Requeue: true}, nil, r.Configuration.RestartJenkinsMasterPod()
|
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)
|
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 && <custom-command-here> && /sbin/tini -s -- /usr/local/bin/jenkins.sh",
|
resources.JenkinsMasterContainerName, []string{"bash", "-c", fmt.Sprintf("%s/%s && <custom-command-here> && /sbin/tini -s -- /usr/local/bin/jenkins.sh",
|
||||||
resources.JenkinsScriptsVolumePath, resources.InitScriptName)}))
|
resources.JenkinsScriptsVolumePath, resources.InitScriptName)}))
|
||||||
}
|
}
|
||||||
*r.Notifications <- notifications.Event{
|
*r.Notifications <- event.Event{
|
||||||
Jenkins: *r.Configuration.Jenkins,
|
Jenkins: *r.Configuration.Jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelInfo,
|
Level: v1alpha2.NotificationLevelInfo,
|
||||||
Message: "Creating a new Jenkins Master Pod",
|
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))
|
r.logger.Info(fmt.Sprintf("Creating a new Jenkins Master Pod %s/%s", jenkinsMasterPod.Namespace, jenkinsMasterPod.Name))
|
||||||
err = r.createResource(jenkinsMasterPod)
|
err = r.createResource(jenkinsMasterPod)
|
||||||
|
|
@ -452,12 +459,13 @@ func (r *ReconcileJenkinsBaseConfiguration) ensureJenkinsMasterPod(meta metav1.O
|
||||||
return reconcile.Result{Requeue: true}, nil
|
return reconcile.Result{Requeue: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := r.isRecreatePodNeeded(*currentJenkinsMasterPod, userAndPasswordHash)
|
restartReason := r.checkForPodRecreation(*currentJenkinsMasterPod, userAndPasswordHash)
|
||||||
if hasMessages := len(messages) > 0; hasMessages {
|
if restartReason.HasMessages() {
|
||||||
for _, msg := range messages {
|
for _, msg := range restartReason.Verbose() {
|
||||||
r.logger.Info(msg)
|
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
|
return reconcile.Result{}, nil
|
||||||
|
|
@ -480,59 +488,76 @@ func isPodTerminating(pod corev1.Pod) bool {
|
||||||
return pod.ObjectMeta.DeletionTimestamp != nil
|
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
|
var messages []string
|
||||||
if userAndPasswordHash != r.Configuration.Jenkins.Status.UserAndPasswordHash {
|
var verbose []string
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentJenkinsMasterPod.Status.Phase == corev1.PodFailed ||
|
if currentJenkinsMasterPod.Status.Phase == corev1.PodFailed ||
|
||||||
currentJenkinsMasterPod.Status.Phase == corev1.PodSucceeded ||
|
currentJenkinsMasterPod.Status.Phase == corev1.PodSucceeded ||
|
||||||
currentJenkinsMasterPod.Status.Phase == corev1.PodUnknown {
|
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) {
|
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))
|
currentJenkinsMasterPod.Spec.SecurityContext, r.Configuration.Jenkins.Spec.Master.SecurityContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.ImagePullSecrets, currentJenkinsMasterPod.Spec.ImagePullSecrets) {
|
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))
|
currentJenkinsMasterPod.Spec.ImagePullSecrets, r.Configuration.Jenkins.Spec.Master.ImagePullSecrets))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.NodeSelector, currentJenkinsMasterPod.Spec.NodeSelector) {
|
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))
|
currentJenkinsMasterPod.Spec.NodeSelector, r.Configuration.Jenkins.Spec.Master.NodeSelector))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.Configuration.Jenkins.Spec.Master.Annotations) > 0 &&
|
if len(r.Configuration.Jenkins.Spec.Master.Annotations) > 0 &&
|
||||||
!reflect.DeepEqual(r.Configuration.Jenkins.Spec.Master.Annotations, currentJenkinsMasterPod.ObjectMeta.Annotations) {
|
!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) {
|
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))
|
currentJenkinsMasterPod.Spec.Volumes, r.Configuration.Jenkins.Spec.Master.Volumes))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.Configuration.Jenkins.Spec.Master.Containers) != len(currentJenkinsMasterPod.Spec.Containers) {
|
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 {
|
for _, actualContainer := range currentJenkinsMasterPod.Spec.Containers {
|
||||||
if actualContainer.Name == resources.JenkinsMasterContainerName {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,76 +570,79 @@ func (r *ReconcileJenkinsBaseConfiguration) isRecreatePodNeeded(currentJenkinsMa
|
||||||
}
|
}
|
||||||
|
|
||||||
if expectedContainer == nil {
|
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 {
|
func (r *ReconcileJenkinsBaseConfiguration) compareContainers(expected corev1.Container, actual corev1.Container) (messages []string, verbose []string) {
|
||||||
var messages []string
|
|
||||||
|
|
||||||
if !reflect.DeepEqual(expected.Args, actual.Args) {
|
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, "Arguments have changed")
|
||||||
messages = append(messages, fmt.Sprintf("Arguments have changed to '%+v' in container '%s', recreating pod", expected.Args, expected.Name))
|
verbose = append(messages, fmt.Sprintf("Arguments have changed to '%+v' in container '%s'", expected.Args, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.Command, actual.Command) {
|
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, "Command has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Command has changed to '%+v' in container '%s', recreating pod", expected.Command, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Command has changed to '%+v' in container '%s'", expected.Command, expected.Name))
|
||||||
}
|
}
|
||||||
if !compareEnv(expected.Env, actual.Env) {
|
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, "Env has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Env has changed to '%+v' in container '%s', recreating pod", expected.Env, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Env has changed to '%+v' in container '%s'", expected.Env, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.EnvFrom, actual.EnvFrom) {
|
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, "EnvFrom has changed")
|
||||||
messages = append(messages, fmt.Sprintf("EnvFrom has changed to '%+v' in container '%s', recreating pod", expected.EnvFrom, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("EnvFrom has changed to '%+v' in container '%s'", expected.EnvFrom, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.Image, actual.Image) {
|
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, "Image has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Image has changed to '%+v' in container '%s', recreating pod", expected.Image, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Image has changed to '%+v' in container '%s'", expected.Image, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.ImagePullPolicy, actual.ImagePullPolicy) {
|
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, "Image pull policy has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Image pull policy has changed to '%+v' in container '%s', recreating pod", expected.ImagePullPolicy, expected.Name))
|
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) {
|
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, "Lifecycle has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Lifecycle has changed to '%+v' in container '%s', recreating pod", expected.Lifecycle, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Lifecycle has changed to '%+v' in container '%s'", expected.Lifecycle, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.LivenessProbe, actual.LivenessProbe) {
|
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, "Liveness probe has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Liveness probe has changed to '%+v' in container '%s', recreating pod", expected.LivenessProbe, expected.Name))
|
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) {
|
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, "Ports have changed")
|
||||||
messages = append(messages, fmt.Sprintf("Ports have changed to '%+v' in container '%s', recreating pod", expected.Ports, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Ports have changed to '%+v' in container '%s'", expected.Ports, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.ReadinessProbe, actual.ReadinessProbe) {
|
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, "Readiness probe has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Readiness probe has changed to '%+v' in container '%s', recreating pod", expected.ReadinessProbe, expected.Name))
|
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) {
|
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, "Resources have changed")
|
||||||
messages = append(messages, fmt.Sprintf("Resources have changed to '%+v' in container '%s', recreating pod", expected.Resources, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Resources have changed to '%+v' in container '%s'", expected.Resources, expected.Name))
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(expected.SecurityContext, actual.SecurityContext) {
|
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, "Security context has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Security context has changed to '%+v' in container '%s', recreating pod", expected.SecurityContext, expected.Name))
|
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) {
|
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, "Working directory has changed")
|
||||||
messages = append(messages, fmt.Sprintf("Working directory has changed to '%+v' in container '%s', recreating pod", expected.WorkingDir, expected.Name))
|
verbose = append(verbose, fmt.Sprintf("Working directory has changed to '%+v' in container '%s'", expected.WorkingDir, expected.Name))
|
||||||
}
|
}
|
||||||
if !CompareContainerVolumeMounts(expected, actual) {
|
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, "Volume mounts have changed")
|
||||||
messages = append(messages, fmt.Sprintf("Volume mounts have changed to '%+v' in container '%s', recreating pod", expected.VolumeMounts, expected.Name))
|
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 {
|
func compareEnv(expected, actual []corev1.EnvVar) bool {
|
||||||
|
|
@ -723,8 +751,14 @@ func (r *ReconcileJenkinsBaseConfiguration) waitForJenkins(meta metav1.ObjectMet
|
||||||
containersReadyCount := 0
|
containersReadyCount := 0
|
||||||
for _, containerStatus := range jenkinsMasterPod.Status.ContainerStatuses {
|
for _, containerStatus := range jenkinsMasterPod.Status.ContainerStatuses {
|
||||||
if containerStatus.State.Terminated != nil {
|
if containerStatus.State.Terminated != nil {
|
||||||
r.logger.Info(fmt.Sprintf("Container '%s' is terminated, status '%+v', recreating pod", containerStatus.Name, containerStatus))
|
message := fmt.Sprintf("Container '%s' is terminated, status '%+v'", containerStatus.Name, containerStatus)
|
||||||
return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod()
|
r.logger.Info(message)
|
||||||
|
|
||||||
|
restartReason := reason.NewPodRestart(
|
||||||
|
reason.KubernetesSource,
|
||||||
|
[]string{message},
|
||||||
|
)
|
||||||
|
return reconcile.Result{Requeue: true}, r.Configuration.RestartJenkinsMasterPod(restartReason)
|
||||||
}
|
}
|
||||||
if !containerStatus.Ready {
|
if !containerStatus.Ready {
|
||||||
r.logger.V(log.VDebug).Info(fmt.Sprintf("Container '%s' not ready, readiness probe failed", containerStatus.Name))
|
r.logger.V(log.VDebug).Info(fmt.Sprintf("Container '%s' not ready, readiness probe failed", containerStatus.Name))
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
|
"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/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"
|
stackerr "github.com/pkg/errors"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
|
@ -19,23 +19,22 @@ import (
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
Client client.Client
|
Client client.Client
|
||||||
ClientSet kubernetes.Clientset
|
ClientSet kubernetes.Clientset
|
||||||
Notifications *chan notifications.Event
|
Notifications *chan event.Event
|
||||||
Jenkins *v1alpha2.Jenkins
|
Jenkins *v1alpha2.Jenkins
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartJenkinsMasterPod terminate Jenkins master pod and notifies about it
|
// 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()
|
currentJenkinsMasterPod, err := c.getJenkinsMasterPod()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
*c.Notifications <- notifications.Event{
|
*c.Notifications <- event.Event{
|
||||||
Jenkins: *c.Jenkins,
|
Jenkins: *c.Jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelInfo,
|
Level: v1alpha2.NotificationLevelInfo,
|
||||||
Message: fmt.Sprintf("Terminating Jenkins Master Pod %s/%s.", currentJenkinsMasterPod.Namespace, currentJenkinsMasterPod.Name),
|
Reason: reason,
|
||||||
MessagesVerbose: []string{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stackerr.WithStack(c.Client.Delete(context.TODO(), currentJenkinsMasterPod))
|
return stackerr.WithStack(c.Client.Delete(context.TODO(), currentJenkinsMasterPod))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package user
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
||||||
jenkinsclient "github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/client"
|
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"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/backuprestore"
|
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/backuprestore"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/base/resources"
|
"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/constants"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy"
|
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/groovy"
|
||||||
|
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/notifications/reason"
|
||||||
|
|
||||||
"github.com/go-logr/logr"
|
"github.com/go-logr/logr"
|
||||||
stackerr "github.com/pkg/errors"
|
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
|
// 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) {
|
func (s *SeedJobs) EnsureSeedJobs(jenkins *v1alpha2.Jenkins) (done bool, err error) {
|
||||||
if s.isRecreatePodNeeded(*jenkins) {
|
if s.isRecreatePodNeeded(*jenkins) {
|
||||||
s.logger.Info("Some seed job has been deleted, recreating pod")
|
message := "Some seed job has been deleted, recreating pod"
|
||||||
return false, s.RestartJenkinsMasterPod()
|
s.logger.Info(message)
|
||||||
|
|
||||||
|
restartReason := reason.NewPodRestart(
|
||||||
|
reason.OperatorSource,
|
||||||
|
[]string{message},
|
||||||
|
)
|
||||||
|
return false, s.RestartJenkinsMasterPod(restartReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(jenkins.Spec.SeedJobs) > 0 {
|
if len(jenkins.Spec.SeedJobs) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -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/base/resources"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/controller/jenkins/configuration/user"
|
"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/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/controller/jenkins/plugins"
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
"github.com/jenkinsci/kubernetes-operator/pkg/log"
|
||||||
"github.com/jenkinsci/kubernetes-operator/version"
|
"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
|
// 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.
|
// 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))
|
return add(mgr, newReconciler(mgr, local, minikube, clientSet, config, notificationEvents))
|
||||||
}
|
}
|
||||||
|
|
||||||
// newReconciler returns a new reconcile.Reconciler
|
// 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{
|
return &ReconcileJenkins{
|
||||||
client: mgr.GetClient(),
|
client: mgr.GetClient(),
|
||||||
scheme: mgr.GetScheme(),
|
scheme: mgr.GetScheme(),
|
||||||
|
|
@ -116,7 +117,7 @@ type ReconcileJenkins struct {
|
||||||
local, minikube bool
|
local, minikube bool
|
||||||
clientSet kubernetes.Clientset
|
clientSet kubernetes.Clientset
|
||||||
config rest.Config
|
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
|
// 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))
|
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{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelWarning,
|
Level: v1alpha2.NotificationLevelWarning,
|
||||||
Message: fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %s", reconcileFailLimit, err),
|
Reason: reason.NewReconcileLoopFailed(
|
||||||
MessagesVerbose: []string{fmt.Sprintf("Reconcile loop failed %d times with the same error, giving up: %+v", reconcileFailLimit, err)},
|
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
|
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 {
|
if groovyErr, ok := err.(*jenkinsclient.GroovyScriptExecutionFailed); ok {
|
||||||
*r.notificationEvents <- notifications.Event{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelWarning,
|
Level: v1alpha2.NotificationLevelWarning,
|
||||||
Message: fmt.Sprintf("%s Source '%s' Name '%s' groovy script execution failed, logs:", groovyErr.ConfigurationType, groovyErr.Source, groovyErr.Name),
|
Reason: reason.NewGroovyScriptExecutionFailed(
|
||||||
MessagesVerbose: []string{groovyErr.Logs},
|
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
|
return reconcile.Result{Requeue: false}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -213,21 +219,20 @@ func (r *ReconcileJenkins) reconcile(request reconcile.Request, logger logr.Logg
|
||||||
// Reconcile base configuration
|
// Reconcile base configuration
|
||||||
baseConfiguration := base.New(config, r.scheme, logger, r.local, r.minikube, &r.config)
|
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 {
|
if err != nil {
|
||||||
return reconcile.Result{}, jenkins, err
|
return reconcile.Result{}, jenkins, err
|
||||||
}
|
}
|
||||||
if len(messages) > 0 {
|
if len(baseMessages) > 0 {
|
||||||
message := "Validation of base configuration failed, please correct Jenkins CR."
|
message := "Validation of base configuration failed, please correct Jenkins CR."
|
||||||
*r.notificationEvents <- notifications.Event{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelWarning,
|
Level: v1alpha2.NotificationLevelWarning,
|
||||||
Message: message,
|
Reason: reason.NewBaseConfigurationFailed(reason.HumanSource, []string{message}, append([]string{message}, baseMessages...)...),
|
||||||
MessagesVerbose: messages,
|
|
||||||
}
|
}
|
||||||
logger.V(log.VWarn).Info(message)
|
logger.V(log.VWarn).Info(message)
|
||||||
for _, msg := range messages {
|
for _, msg := range baseMessages {
|
||||||
logger.V(log.VWarn).Info(msg)
|
logger.V(log.VWarn).Info(msg)
|
||||||
}
|
}
|
||||||
return reconcile.Result{}, jenkins, nil // don't requeue
|
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",
|
message := fmt.Sprintf("Base configuration phase is complete, took %s",
|
||||||
jenkins.Status.BaseConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time))
|
jenkins.Status.BaseConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time))
|
||||||
*r.notificationEvents <- notifications.Event{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseBase,
|
Phase: event.PhaseBase,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelInfo,
|
Level: v1alpha2.NotificationLevelInfo,
|
||||||
Message: message,
|
Reason: reason.NewBaseConfigurationComplete(reason.OperatorSource, []string{message}),
|
||||||
MessagesVerbose: messages,
|
|
||||||
}
|
}
|
||||||
logger.Info(message)
|
logger.Info(message)
|
||||||
}
|
}
|
||||||
// Reconcile user configuration
|
// Reconcile user configuration
|
||||||
userConfiguration := user.New(config, jenkinsClient, logger, r.config)
|
userConfiguration := user.New(config, jenkinsClient, logger, r.config)
|
||||||
|
|
||||||
messages, err = userConfiguration.Validate(jenkins)
|
messages, err := userConfiguration.Validate(jenkins)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reconcile.Result{}, jenkins, err
|
return reconcile.Result{}, jenkins, err
|
||||||
}
|
}
|
||||||
if len(messages) > 0 {
|
if len(messages) > 0 {
|
||||||
message := fmt.Sprintf("Validation of user configuration failed, please correct Jenkins CR")
|
message := fmt.Sprintf("Validation of user configuration failed, please correct Jenkins CR")
|
||||||
*r.notificationEvents <- notifications.Event{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseUser,
|
Phase: event.PhaseUser,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelWarning,
|
Level: v1alpha2.NotificationLevelWarning,
|
||||||
Message: message,
|
Reason: reason.NewUserConfigurationFailed(reason.HumanSource, []string{message}, append([]string{message}, messages...)...),
|
||||||
MessagesVerbose: messages,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.V(log.VWarn).Info(message)
|
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",
|
message := fmt.Sprintf("User configuration phase is complete, took %s",
|
||||||
jenkins.Status.UserConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time))
|
jenkins.Status.UserConfigurationCompletedTime.Sub(jenkins.Status.ProvisionStartTime.Time))
|
||||||
*r.notificationEvents <- notifications.Event{
|
*r.notificationEvents <- event.Event{
|
||||||
Jenkins: *jenkins,
|
Jenkins: *jenkins,
|
||||||
Phase: notifications.PhaseUser,
|
Phase: event.PhaseUser,
|
||||||
LogLevel: v1alpha2.NotificationLogLevelInfo,
|
Level: v1alpha2.NotificationLevelInfo,
|
||||||
Message: message,
|
Reason: reason.NewUserConfigurationComplete(reason.OperatorSource, []string{message}),
|
||||||
MessagesVerbose: messages,
|
|
||||||
}
|
}
|
||||||
logger.Info(message)
|
logger.Info(message)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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 = `
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
||||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html>
|
|
||||||
<head></head>
|
|
||||||
<body>
|
|
||||||
<h1 style="background-color: %s; color: white; padding: 3px 10px;">%s</h1>
|
|
||||||
<h3>%s</h3>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td><b>CR name:</b></td>
|
|
||||||
<td>%s</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>Phase:</b></td>
|
|
||||||
<td>%s</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<h6 style="font-size: 11px; color: grey; margin-top: 15px;">Powered by Jenkins Operator <3</h6>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
// 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 + "<ul>"
|
|
||||||
for _, msg := range event.MessagesVerbose {
|
|
||||||
message = message + "<li>" + msg + "</li>"
|
|
||||||
}
|
|
||||||
message = message + "</ul>"
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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 = `
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<h1 style="background-color: %s; color: white; padding: 3px 10px;">%s</h1>
|
||||||
|
<h3>%s</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><b>CR name:</b></td>
|
||||||
|
<td>%s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Phase:</b></td>
|
||||||
|
<td>%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h6 style="font-size: 11px; color: grey; margin-top: 15px;">Powered by Jenkins Operator <3</h6>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(), "</li><li>"), "<li>")
|
||||||
|
} else {
|
||||||
|
reasons = strings.TrimRight(strings.Join(event.Reason.Short(), "</li><li>"), "<li>")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(reasons)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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(), "</li><li>"), "<li>")
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(r)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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(), "</li><li>"), "<li>")
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(r)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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(), "</li><li>"), "<li>")
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(r)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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(), "</li><li>"), "<li>")
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(r)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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:")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -3,130 +3,90 @@ package notifications
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
|
"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/jenkinsci/kubernetes-operator/pkg/log"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
|
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// Provider is the communication service handler
|
||||||
infoTitleText = "Jenkins Operator reconciliation info"
|
type Provider interface {
|
||||||
warnTitleText = "Jenkins Operator reconciliation warning"
|
Send(event event.Event) error
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen listens for incoming events and send it as notifications
|
// Listen listens for incoming events and send it as notifications
|
||||||
func Listen(events chan Event, k8sEvent event.Recorder, k8sClient k8sclient.Client) {
|
func Listen(events chan event.Event, k8sEvent k8sevent.Recorder, k8sClient k8sclient.Client) {
|
||||||
for evt := range events {
|
httpClient := http.Client{}
|
||||||
logger := log.Log.WithValues("cr", evt.Jenkins.Name)
|
for e := range events {
|
||||||
for _, notificationConfig := range evt.Jenkins.Spec.Notifications {
|
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 err error
|
||||||
var svc service
|
var provider Provider
|
||||||
|
|
||||||
if notificationConfig.Slack != nil {
|
if notificationConfig.Slack != nil {
|
||||||
svc = Slack{k8sClient: k8sClient}
|
provider = slack.New(k8sClient, notificationConfig, httpClient)
|
||||||
} else if notificationConfig.Teams != nil {
|
} else if notificationConfig.Teams != nil {
|
||||||
svc = Teams{k8sClient: k8sClient}
|
provider = msteams.New(k8sClient, notificationConfig, httpClient)
|
||||||
} else if notificationConfig.Mailgun != nil {
|
} else if notificationConfig.Mailgun != nil {
|
||||||
svc = MailGun{k8sClient: k8sClient}
|
provider = mailgun.New(k8sClient, notificationConfig)
|
||||||
} else if notificationConfig.SMTP != nil {
|
} else if notificationConfig.SMTP != nil {
|
||||||
svc = SMTP{k8sClient: k8sClient}
|
provider = smtp.New(k8sClient, notificationConfig)
|
||||||
} else {
|
} else {
|
||||||
logger.V(log.VWarn).Info(fmt.Sprintf("Unknown notification service `%+v`", notificationConfig))
|
logger.V(log.VWarn).Info(fmt.Sprintf("Unknown notification service `%+v`", notificationConfig))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(notificationConfig v1alpha2.Notification) {
|
isInfoEvent := e.Level == v1alpha2.NotificationLevelInfo
|
||||||
err = notify(svc, evt, notificationConfig)
|
wantsWarning := notificationConfig.LoggingLevel == v1alpha2.NotificationLevelWarning
|
||||||
|
if isInfoEvent && wantsWarning {
|
||||||
|
continue // skip the event
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(notificationConfig v1alpha2.Notification) {
|
||||||
|
err = provider.Send(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
wrapped := errors.WithMessage(err,
|
||||||
|
fmt.Sprintf("failed to send notification '%s'", notificationConfig.Name))
|
||||||
if log.Debug {
|
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 {
|
} 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)
|
}(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 {
|
switch level {
|
||||||
case v1alpha2.NotificationLogLevelWarning:
|
case v1alpha2.NotificationLevelWarning:
|
||||||
return event.TypeWarning
|
return k8sevent.TypeWarning
|
||||||
case v1alpha2.NotificationLogLevelInfo:
|
case v1alpha2.NotificationLevelInfo:
|
||||||
return event.TypeNormal
|
return k8sevent.TypeNormal
|
||||||
default:
|
default:
|
||||||
return event.TypeNormal
|
return k8sevent.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 ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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 + "<ul>"
|
|
||||||
for _, msg := range event.MessagesVerbose {
|
|
||||||
message = message + "<li>" + msg + "</li>"
|
|
||||||
}
|
|
||||||
message = message + "</ul>"
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 = `
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<h1 style="background-color: %s; color: white; padding: 3px 10px;">%s</h1>
|
||||||
|
<h3>%s</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><b>CR name:</b></td>
|
||||||
|
<td>%s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Phase:</b></td>
|
||||||
|
<td>%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h6 style="font-size: 11px; color: grey; margin-top: 15px;">Powered by Jenkins Operator <3</h6>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(), "</li><li>"), "<li>")
|
||||||
|
} else {
|
||||||
|
reasons = strings.TrimRight(strings.Join(e.Reason.Short(), "</li><li>"), "<li>")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMessage.WriteString("<ul><li>")
|
||||||
|
statusMessage.WriteString(reasons)
|
||||||
|
statusMessage.WriteString("</ul>")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package notifications
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -13,6 +13,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2"
|
"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/emersion/go-smtp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -37,8 +39,20 @@ const (
|
||||||
subjectHeader = "Subject"
|
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 {
|
type testServer struct {
|
||||||
event Event
|
event event.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles a login command with username and password.
|
// 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.
|
// A Session is returned after successful login.
|
||||||
type testSession struct {
|
type testSession struct {
|
||||||
event Event
|
event event.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *testSession) Mail(from string) error {
|
func (s *testSession) Mail(from string) error {
|
||||||
|
|
@ -111,17 +125,16 @@ func (s *testSession) Logout() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSMTP_Send(t *testing.T) {
|
func TestSMTP_Send(t *testing.T) {
|
||||||
event := Event{
|
event := event.Event{
|
||||||
Jenkins: v1alpha2.Jenkins{
|
Jenkins: v1alpha2.Jenkins{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: testCrName,
|
Name: testCrName,
|
||||||
Namespace: testNamespace,
|
Namespace: testNamespace,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Phase: testPhase,
|
Phase: testPhase,
|
||||||
Message: testMessage,
|
Level: testLevel,
|
||||||
MessagesVerbose: testMessageVerbose,
|
Reason: testReason,
|
||||||
LogLevel: testLoggingLevel,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fakeClient := fake.NewFakeClient()
|
fakeClient := fake.NewFakeClient()
|
||||||
|
|
@ -129,7 +142,27 @@ func TestSMTP_Send(t *testing.T) {
|
||||||
testPasswordSelectorKeyName := "test-password-selector"
|
testPasswordSelectorKeyName := "test-password-selector"
|
||||||
testSecretName := "test-secret"
|
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}
|
ts := &testServer{event: event}
|
||||||
|
|
||||||
|
|
@ -169,27 +202,108 @@ func TestSMTP_Send(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = smtpClient.Send(event, v1alpha2.Notification{
|
err = smtpClient.Send(event)
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue