mirror of
https://github.com/prometheus/alertmanager
synced 2025-02-27 00:00:43 +00:00
* notify/email: wrap all errors for easier debugging In addition, this commit passes the current context to the TCP dialer and it doesn't log any QUIT errors if the email delivery wasn't successful. Signed-off-by: Simon Pasquier <spasquie@redhat.com> * Fix typo Signed-off-by: Simon Pasquier <spasquie@redhat.com>
604 lines
16 KiB
Go
604 lines
16 KiB
Go
// Copyright 2019 Prometheus Team
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Some tests require a running mail catcher. We use MailDev for this purpose,
|
|
// it can work without or with authentication (LOGIN only). It exposes a REST
|
|
// API which we use to retrieve and check the sent emails.
|
|
//
|
|
// Those tests are only executed when specific environment variables are set,
|
|
// otherwise they are skipped. The tests must be run by the CI.
|
|
//
|
|
// To run the tests locally, you should start 2 MailDev containers:
|
|
//
|
|
// $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v
|
|
// $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v
|
|
//
|
|
// $ EMAIL_NO_AUTH_CONFIG=testdata/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
|
|
//
|
|
// See also https://github.com/djfarrelly/MailDev for more details.
|
|
package email
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-kit/kit/log"
|
|
commoncfg "github.com/prometheus/common/config"
|
|
"github.com/prometheus/common/model"
|
|
"github.com/stretchr/testify/require"
|
|
yaml "gopkg.in/yaml.v2"
|
|
|
|
"github.com/prometheus/alertmanager/config"
|
|
"github.com/prometheus/alertmanager/template"
|
|
"github.com/prometheus/alertmanager/types"
|
|
)
|
|
|
|
const (
|
|
emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG"
|
|
emailAuthConfigVar = "EMAIL_AUTH_CONFIG"
|
|
|
|
emailTo = "alerts@example.com"
|
|
emailFrom = "alertmanager@example.com"
|
|
)
|
|
|
|
// email represents an email returned by the MailDev REST API.
|
|
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
|
|
type email struct {
|
|
To []map[string]string
|
|
From []map[string]string
|
|
Subject string
|
|
HTML *string
|
|
Text *string
|
|
Headers map[string]string
|
|
}
|
|
|
|
// mailDev is a client for the MailDev server.
|
|
type mailDev struct {
|
|
*url.URL
|
|
}
|
|
|
|
func (m *mailDev) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
var s string
|
|
if err := unmarshal(&s); err != nil {
|
|
return err
|
|
}
|
|
urlp, err := url.Parse(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.URL = urlp
|
|
return nil
|
|
}
|
|
|
|
// getLastEmail returns the last received email.
|
|
func (m *mailDev) getLastEmail() (*email, error) {
|
|
code, b, err := m.doEmailRequest(http.MethodGet, "/email")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if code != http.StatusOK {
|
|
return nil, fmt.Errorf("expected status OK, got %d", code)
|
|
}
|
|
|
|
var emails []email
|
|
err = yaml.Unmarshal(b, &emails)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(emails) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &emails[len(emails)-1], nil
|
|
}
|
|
|
|
// deleteAllEmails deletes all emails.
|
|
func (m *mailDev) deleteAllEmails() error {
|
|
_, _, err := m.doEmailRequest(http.MethodDelete, "/email/all")
|
|
return err
|
|
}
|
|
|
|
// doEmailRequest makes a request to the MailDev API.
|
|
func (m *mailDev) doEmailRequest(method string, path string) (int, []byte, error) {
|
|
req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
req = req.WithContext(ctx)
|
|
defer cancel()
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
b, err := ioutil.ReadAll(res.Body)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return res.StatusCode, b, nil
|
|
}
|
|
|
|
// emailTestConfig is the configuration for the tests.
|
|
type emailTestConfig struct {
|
|
Smarthost string `yaml:"smarthost"`
|
|
Username string `yaml:"username"`
|
|
Password string `yaml:"password"`
|
|
Server *mailDev `yaml:"server"`
|
|
}
|
|
|
|
func loadEmailTestConfiguration(f string) (emailTestConfig, error) {
|
|
c := emailTestConfig{}
|
|
b, err := ioutil.ReadFile(f)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
|
|
err = yaml.UnmarshalStrict(b, &c)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func notifyEmail(cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
|
|
return notifyEmailWithContext(context.Background(), cfg, server)
|
|
}
|
|
|
|
// notifyEmailWithContext sends a notification with one firing alert and retrieves the
|
|
// email from the SMTP server if the notification has been successfully delivered.
|
|
func notifyEmailWithContext(ctx context.Context, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
|
|
if cfg.RequireTLS == nil {
|
|
cfg.RequireTLS = new(bool)
|
|
}
|
|
if cfg.Headers == nil {
|
|
cfg.Headers = make(map[string]string)
|
|
}
|
|
firingAlert := &types.Alert{
|
|
Alert: model.Alert{
|
|
Labels: model.LabelSet{},
|
|
StartsAt: time.Now(),
|
|
EndsAt: time.Now().Add(time.Hour),
|
|
},
|
|
}
|
|
err := server.deleteAllEmails()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
tmpl, err := template.FromGlobs()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
tmpl.ExternalURL, _ = url.Parse("http://am")
|
|
email := New(cfg, tmpl, log.NewNopLogger())
|
|
|
|
retry, err := email.Notify(ctx, firingAlert)
|
|
if err != nil {
|
|
return nil, retry, err
|
|
}
|
|
|
|
e, err := server.getLastEmail()
|
|
if err != nil {
|
|
return nil, retry, err
|
|
} else if e == nil {
|
|
return nil, retry, fmt.Errorf("email not found")
|
|
}
|
|
return e, retry, nil
|
|
}
|
|
|
|
// TestEmailNotifyWithErrors tries to send emails with buggy inputs.
|
|
func TestEmailNotifyWithErrors(t *testing.T) {
|
|
cfgFile := os.Getenv(emailNoAuthConfigVar)
|
|
if len(cfgFile) == 0 {
|
|
t.Skipf("%s not set", emailNoAuthConfigVar)
|
|
}
|
|
|
|
c, err := loadEmailTestConfiguration(cfgFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
title string
|
|
updateCfg func(*config.EmailConfig)
|
|
|
|
errMsg string
|
|
hasEmail bool
|
|
}{
|
|
{
|
|
title: "invalid address",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.Smarthost = "example.com"
|
|
},
|
|
errMsg: "split address:",
|
|
},
|
|
{
|
|
title: "invalid 'from' template",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.From = `{{ template "invalid" }}`
|
|
},
|
|
errMsg: "execute 'from' template:",
|
|
},
|
|
{
|
|
title: "invalid 'from' address",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.From = `xxx`
|
|
},
|
|
errMsg: "parse 'from' addresses:",
|
|
},
|
|
{
|
|
title: "invalid 'to' template",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.To = `{{ template "invalid" }}`
|
|
},
|
|
errMsg: "execute 'to' template:",
|
|
},
|
|
{
|
|
title: "invalid 'to' address",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.To = `xxx`
|
|
},
|
|
errMsg: "parse 'to' addresses:",
|
|
},
|
|
{
|
|
title: "invalid 'subject' template",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.Headers["subject"] = `{{ template "invalid" }}`
|
|
},
|
|
errMsg: `execute "subject" header template:`,
|
|
hasEmail: true,
|
|
},
|
|
{
|
|
title: "invalid 'text' template",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.Text = `{{ template "invalid" }}`
|
|
},
|
|
errMsg: `execute text template:`,
|
|
hasEmail: true,
|
|
},
|
|
{
|
|
title: "invalid 'html' template",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.HTML = `{{ template "invalid" }}`
|
|
},
|
|
errMsg: `execute html template:`,
|
|
hasEmail: true,
|
|
},
|
|
} {
|
|
tc := tc
|
|
t.Run(tc.title, func(t *testing.T) {
|
|
if len(tc.errMsg) == 0 {
|
|
t.Fatal("please define the expected error message")
|
|
return
|
|
}
|
|
|
|
emailCfg := &config.EmailConfig{
|
|
Smarthost: c.Smarthost,
|
|
To: emailTo,
|
|
From: emailFrom,
|
|
HTML: "HTML body",
|
|
Text: "Text body",
|
|
Headers: map[string]string{
|
|
"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
|
|
},
|
|
}
|
|
if tc.updateCfg != nil {
|
|
tc.updateCfg(emailCfg)
|
|
}
|
|
|
|
_, retry, err := notifyEmail(emailCfg, c.Server)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tc.errMsg)
|
|
require.Equal(t, false, retry)
|
|
|
|
e, err := c.Server.getLastEmail()
|
|
require.NoError(t, err)
|
|
if tc.hasEmail {
|
|
require.NotNil(t, e)
|
|
} else {
|
|
require.Nil(t, e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailNotifyWithDoneContext tries to send an email with a context that is done.
|
|
func TestEmailNotifyWithDoneContext(t *testing.T) {
|
|
cfgFile := os.Getenv(emailNoAuthConfigVar)
|
|
if len(cfgFile) == 0 {
|
|
t.Skipf("%s not set", emailNoAuthConfigVar)
|
|
}
|
|
|
|
c, err := loadEmailTestConfiguration(cfgFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, _, err = notifyEmailWithContext(
|
|
ctx,
|
|
&config.EmailConfig{
|
|
Smarthost: c.Smarthost,
|
|
To: emailTo,
|
|
From: emailFrom,
|
|
HTML: "HTML body",
|
|
Text: "Text body",
|
|
},
|
|
c.Server,
|
|
)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "establish connection to server")
|
|
}
|
|
|
|
// TestEmailNotifyWithoutAuthentication sends an email to an instance of
|
|
// MailDev configured with no authentication then it checks that the server has
|
|
// successfully processed the email.
|
|
func TestEmailNotifyWithoutAuthentication(t *testing.T) {
|
|
cfgFile := os.Getenv(emailNoAuthConfigVar)
|
|
if len(cfgFile) == 0 {
|
|
t.Skipf("%s not set", emailNoAuthConfigVar)
|
|
}
|
|
|
|
c, err := loadEmailTestConfiguration(cfgFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _, err = notifyEmail(
|
|
&config.EmailConfig{
|
|
Smarthost: c.Smarthost,
|
|
To: emailTo,
|
|
From: emailFrom,
|
|
HTML: "HTML body",
|
|
Text: "Text body",
|
|
},
|
|
c.Server,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection
|
|
// to TLS, sends an email then it checks that the server has successfully
|
|
// processed the email.
|
|
// MailDev doesn't support STARTTLS and authentication at the same time so it
|
|
// is the only way to test successful STARTTLS.
|
|
func TestEmailNotifyWithSTARTTLS(t *testing.T) {
|
|
cfgFile := os.Getenv(emailNoAuthConfigVar)
|
|
if len(cfgFile) == 0 {
|
|
t.Skipf("%s not set", emailNoAuthConfigVar)
|
|
}
|
|
|
|
c, err := loadEmailTestConfiguration(cfgFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
trueVar := true
|
|
_, _, err = notifyEmail(
|
|
&config.EmailConfig{
|
|
Smarthost: c.Smarthost,
|
|
To: emailTo,
|
|
From: emailFrom,
|
|
HTML: "HTML body",
|
|
Text: "Text body",
|
|
RequireTLS: &trueVar,
|
|
// MailDev embeds a self-signed certificate which can't be retrieved.
|
|
TLSConfig: commoncfg.TLSConfig{InsecureSkipVerify: true},
|
|
},
|
|
c.Server,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestEmailNotifyWithAuthentication sends emails to an instance of MailDev
|
|
// configured with authentication.
|
|
func TestEmailNotifyWithAuthentication(t *testing.T) {
|
|
cfgFile := os.Getenv(emailAuthConfigVar)
|
|
if len(cfgFile) == 0 {
|
|
t.Skipf("%s not set", emailAuthConfigVar)
|
|
}
|
|
|
|
c, err := loadEmailTestConfiguration(cfgFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
title string
|
|
updateCfg func(*config.EmailConfig)
|
|
|
|
errMsg string
|
|
retry bool
|
|
}{
|
|
{
|
|
title: "email with authentication",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
},
|
|
},
|
|
{
|
|
title: "HTML-only email",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
cfg.Text = ""
|
|
},
|
|
},
|
|
{
|
|
title: "text-only email",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
cfg.HTML = ""
|
|
},
|
|
},
|
|
{
|
|
title: "multiple To addresses",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
cfg.To = strings.Join([]string{emailTo, emailFrom}, ",")
|
|
},
|
|
},
|
|
{
|
|
title: "no more than one From address",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
cfg.From = strings.Join([]string{emailFrom, emailTo}, ",")
|
|
},
|
|
|
|
errMsg: "must be exactly one 'from' address",
|
|
retry: false,
|
|
},
|
|
{
|
|
title: "wrong credentials",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password + "wrong")
|
|
},
|
|
|
|
errMsg: "Invalid username or password",
|
|
retry: true,
|
|
},
|
|
{
|
|
title: "no credentials",
|
|
errMsg: "authentication Required",
|
|
retry: true,
|
|
},
|
|
{
|
|
title: "try to enable STARTTLS",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.RequireTLS = new(bool)
|
|
*cfg.RequireTLS = true
|
|
},
|
|
|
|
errMsg: "does not advertise the STARTTLS extension",
|
|
retry: true,
|
|
},
|
|
{
|
|
title: "invalid Hello string",
|
|
updateCfg: func(cfg *config.EmailConfig) {
|
|
cfg.AuthUsername = c.Username
|
|
cfg.AuthPassword = config.Secret(c.Password)
|
|
cfg.Hello = "invalid hello string"
|
|
},
|
|
|
|
errMsg: "501 Error",
|
|
retry: true,
|
|
},
|
|
} {
|
|
tc := tc
|
|
t.Run(tc.title, func(t *testing.T) {
|
|
emailCfg := &config.EmailConfig{
|
|
Smarthost: c.Smarthost,
|
|
To: emailTo,
|
|
From: emailFrom,
|
|
HTML: "HTML body",
|
|
Text: "Text body",
|
|
Headers: map[string]string{
|
|
"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
|
|
},
|
|
}
|
|
if tc.updateCfg != nil {
|
|
tc.updateCfg(emailCfg)
|
|
}
|
|
|
|
e, retry, err := notifyEmail(emailCfg, c.Server)
|
|
if len(tc.errMsg) > 0 {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tc.errMsg)
|
|
require.Equal(t, tc.retry, retry)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "1 firing alert(s)", e.Subject)
|
|
|
|
getAddresses := func(addresses []map[string]string) []string {
|
|
res := make([]string, 0, len(addresses))
|
|
for _, addr := range addresses {
|
|
res = append(res, addr["address"])
|
|
}
|
|
return res
|
|
}
|
|
to := getAddresses(e.To)
|
|
from := getAddresses(e.From)
|
|
require.Equal(t, strings.Split(emailCfg.To, ","), to)
|
|
require.Equal(t, strings.Split(emailCfg.From, ","), from)
|
|
|
|
if len(emailCfg.HTML) > 0 {
|
|
require.Equal(t, emailCfg.HTML, *e.HTML)
|
|
} else {
|
|
require.Nil(t, e.HTML)
|
|
}
|
|
|
|
if len(emailCfg.Text) > 0 {
|
|
require.Equal(t, emailCfg.Text, *e.Text)
|
|
} else {
|
|
require.Nil(t, e.Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmailConfigNoAuthMechs(t *testing.T) {
|
|
email := &Email{
|
|
conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
|
|
}
|
|
_, err := email.auth("")
|
|
require.Error(t, err)
|
|
require.Equal(t, err.Error(), "unknown auth mechanism: ")
|
|
}
|
|
|
|
func TestEmailConfigMissingAuthParam(t *testing.T) {
|
|
conf := &config.EmailConfig{AuthUsername: "test"}
|
|
email := &Email{
|
|
conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(),
|
|
}
|
|
_, err := email.auth("CRAM-MD5")
|
|
require.Error(t, err)
|
|
require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism")
|
|
|
|
_, err = email.auth("PLAIN")
|
|
require.Error(t, err)
|
|
require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism")
|
|
|
|
_, err = email.auth("LOGIN")
|
|
require.Error(t, err)
|
|
require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism")
|
|
|
|
_, err = email.auth("PLAIN LOGIN")
|
|
require.Error(t, err)
|
|
require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism")
|
|
}
|
|
|
|
func TestEmailNoUsernameStillOk(t *testing.T) {
|
|
email := &Email{
|
|
conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
|
|
}
|
|
a, err := email.auth("CRAM-MD5")
|
|
require.NoError(t, err)
|
|
require.Nil(t, a)
|
|
}
|