diff --git a/pkg/apis/jenkins/v1alpha2/jenkins_types.go b/pkg/apis/jenkins/v1alpha2/jenkins_types.go index 0ae0c136..5b829be4 100644 --- a/pkg/apis/jenkins/v1alpha2/jenkins_types.go +++ b/pkg/apis/jenkins/v1alpha2/jenkins_types.go @@ -73,6 +73,7 @@ type Notification struct { Slack *Slack `json:"slack,omitempty"` Teams *MicrosoftTeams `json:"teams,omitempty"` Mailgun *Mailgun `json:"mailgun,omitempty"` + SMTP *SMTP `json:"smtp,omitempty"` } // Slack is handler for Slack notification channel @@ -81,6 +82,16 @@ type Slack struct { WebHookURLSecretKeySelector SecretKeySelector `json:"webHookURLSecretKeySelector"` } +type SMTP struct { + UsernameSecretKeySelector SecretKeySelector `json:"usernameSecretKeySelector"` + PasswordSecretKeySelector SecretKeySelector `json:"passwordSecretKeySelector"` + Port int `json:"port"` + Server string `json:"server"` + TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"` + From string `json:"from"` + To string `json:"to"` +} + // MicrosoftTeams is handler for Microsoft MicrosoftTeams notification channel type MicrosoftTeams struct { // The web hook URL to MicrosoftTeams App diff --git a/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go b/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go index 88c845c6..0bb8fec6 100644 --- a/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go +++ b/pkg/apis/jenkins/v1alpha2/zz_generated.deepcopy.go @@ -458,6 +458,11 @@ func (in *Notification) DeepCopyInto(out *Notification) { *out = new(Mailgun) **out = **in } + if in.SMTP != nil { + in, out := &in.SMTP, &out.SMTP + *out = new(SMTP) + **out = **in + } return } @@ -504,6 +509,24 @@ func (in *Restore) DeepCopy() *Restore { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SMTP) DeepCopyInto(out *SMTP) { + *out = *in + out.UsernameSecretKeySelector = in.UsernameSecretKeySelector + out.PasswordSecretKeySelector = in.PasswordSecretKeySelector + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SMTP. +func (in *SMTP) DeepCopy() *SMTP { + if in == nil { + return nil + } + out := new(SMTP) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { *out = *in diff --git a/pkg/controller/jenkins/notifications/sender.go b/pkg/controller/jenkins/notifications/sender.go index fad1219f..d345979b 100644 --- a/pkg/controller/jenkins/notifications/sender.go +++ b/pkg/controller/jenkins/notifications/sender.go @@ -81,6 +81,8 @@ func Listen(events chan Event, k8sEvent event.Recorder, k8sClient k8sclient.Clie svc = Teams{k8sClient: k8sClient} } else if notificationConfig.Mailgun != nil { svc = MailGun{k8sClient: k8sClient} + } else if notificationConfig.SMTP != nil { + svc = SMTP{k8sClient: k8sClient} } else { logger.V(log.VWarn).Info(fmt.Sprintf("Unknown notification service `%+v`", notificationConfig)) continue diff --git a/pkg/controller/jenkins/notifications/smtp.go b/pkg/controller/jenkins/notifications/smtp.go new file mode 100644 index 00000000..8b8b0f28 --- /dev/null +++ b/pkg/controller/jenkins/notifications/smtp.go @@ -0,0 +1,93 @@ +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" +) + +type SMTP struct { + k8sClient k8sclient.Client +} + +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 + "" + 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 { + fmt.Print(err) + 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" + } +} \ No newline at end of file diff --git a/pkg/controller/jenkins/notifications/smtp_test.go b/pkg/controller/jenkins/notifications/smtp_test.go new file mode 100644 index 00000000..0bfaf7af --- /dev/null +++ b/pkg/controller/jenkins/notifications/smtp_test.go @@ -0,0 +1,152 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "github.com/emersion/go-smtp" + "github.com/jenkinsci/kubernetes-operator/pkg/apis/jenkins/v1alpha2" + "github.com/stretchr/testify/assert" + "io" + "io/ioutil" + "log" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + testSMTPUsername = "username" + testSMTPPassword = "password" + + testSMTPPort = 1025 +) + +type testServer struct{} + +// Login handles a login command with username and password. +func (bkd *testServer) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { + if username != testSMTPUsername || password != testSMTPPassword { + return nil, errors.New("invalid username or password") + } + return &testSession{}, nil +} + +// AnonymousLogin requires clients to authenticate using SMTP AUTH before sending emails +func (bkd *testServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { + return nil, smtp.ErrAuthRequired +} + +// A Session is returned after successful login. +type testSession struct{} + +func (s *testSession) Mail(from string) error { + log.Println("Mail from:", from) + return nil +} + +func (s *testSession) Rcpt(to string) error { + log.Println("Rcpt to:", to) + return nil +} + +func (s *testSession) Data(r io.Reader) error { + if b, err := ioutil.ReadAll(r); err != nil { + return err + } else { + log.Println("Data:", string(b)) + } + return nil +} + +func (s *testSession) Reset() {} + +func (s *testSession) Logout() error { + return nil +} + + +func TestSMTP_Send(t *testing.T) { + fakeClient := fake.NewFakeClient() + testUsernameSelectorKeyName := "test-username-selector" + testPasswordSelectorKeyName := "test-password-selector" + testSecretName := "test-secret" + + event := Event{ + Jenkins: v1alpha2.Jenkins{ + ObjectMeta: metav1.ObjectMeta{ + Name: testCrName, + Namespace: testNamespace, + }, + }, + Phase: testPhase, + Message: testMessage, + MessagesVerbose: testMessageVerbose, + LogLevel: testLoggingLevel, + } + + smtpClient := SMTP{k8sClient: fakeClient} + + ts := &testServer{} + + // Create fake SMTP server + + s := smtp.NewServer(ts) + + s.Addr = fmt.Sprintf(":%d", testSMTPPort) + s.Domain = "localhost" + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second + s.MaxMessageBytes = 1024 * 1024 + s.MaxRecipients = 50 + s.AllowInsecureAuth = true + + // Create secrets + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSecretName, + Namespace: testNamespace, + }, + + Data: map[string][]byte{ + testUsernameSelectorKeyName: []byte(testSMTPUsername), + testPasswordSelectorKeyName: []byte(testSMTPPassword), + }, + } + + err := fakeClient.Create(context.TODO(), secret) + assert.NoError(t, err) + + go func() { + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } + }() + + err = smtpClient.Send(event, v1alpha2.Notification{ + SMTP: &v1alpha2.SMTP{ + Server: "localhost", + From: "test@localhost", + To: "test@localhost", + 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) +}