diff --git a/go.mod b/go.mod index 60f28199..41eb100d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 github.com/aws/aws-sdk-go v1.50.29 github.com/benbjohnson/clock v1.3.5 + github.com/eclipse/paho.mqtt.golang v1.4.3 github.com/go-kit/log v0.2.1 github.com/go-openapi/strfmt v0.22.0 github.com/google/go-cmp v0.6.0 @@ -47,6 +48,7 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/uuid v1.5.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect @@ -81,6 +83,7 @@ require ( github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/spf13/cast v1.3.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect go.mongodb.org/mongo-driver v1.13.1 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/mod v0.14.0 // indirect diff --git a/go.sum b/go.sum index 76f504ea..bc39e86a 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= +github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -187,6 +189,8 @@ github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45 h1:AJKOtDKAOg8XNFnIZSmqqqutoTSxVlRs6vekL2p2KEY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -342,11 +346,16 @@ github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= diff --git a/notify/factory.go b/notify/factory.go index 593268b0..1f6b955e 100644 --- a/notify/factory.go +++ b/notify/factory.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/alerting/receivers/googlechat" "github.com/grafana/alerting/receivers/kafka" "github.com/grafana/alerting/receivers/line" + "github.com/grafana/alerting/receivers/mqtt" "github.com/grafana/alerting/receivers/oncall" "github.com/grafana/alerting/receivers/opsgenie" "github.com/grafana/alerting/receivers/pagerduty" @@ -96,6 +97,9 @@ func BuildReceiverIntegrations( for i, cfg := range receiver.LineConfigs { ci(i, cfg.Metadata, line.New(cfg.Settings, cfg.Metadata, tmpl, nw(cfg.Metadata), nl(cfg.Metadata))) } + for i, cfg := range receiver.MqttConfigs { + ci(i, cfg.Metadata, mqtt.New(cfg.Settings, cfg.Metadata, tmpl, nl(cfg.Metadata), nil)) + } for i, cfg := range receiver.OnCallConfigs { ci(i, cfg.Metadata, oncall.New(cfg.Settings, cfg.Metadata, tmpl, nw(cfg.Metadata), img, nl(cfg.Metadata), orgID)) } diff --git a/notify/receivers.go b/notify/receivers.go index ff4dfb9c..a49f6534 100644 --- a/notify/receivers.go +++ b/notify/receivers.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/alerting/receivers/googlechat" "github.com/grafana/alerting/receivers/kafka" "github.com/grafana/alerting/receivers/line" + "github.com/grafana/alerting/receivers/mqtt" "github.com/grafana/alerting/receivers/oncall" "github.com/grafana/alerting/receivers/opsgenie" "github.com/grafana/alerting/receivers/pagerduty" @@ -190,6 +191,7 @@ type GrafanaReceiverConfig struct { KafkaConfigs []*NotifierConfig[kafka.Config] LineConfigs []*NotifierConfig[line.Config] OpsgenieConfigs []*NotifierConfig[opsgenie.Config] + MqttConfigs []*NotifierConfig[mqtt.Config] PagerdutyConfigs []*NotifierConfig[pagerduty.Config] OnCallConfigs []*NotifierConfig[oncall.Config] PushoverConfigs []*NotifierConfig[pushover.Config] @@ -298,6 +300,12 @@ func parseNotifier(ctx context.Context, result *GrafanaReceiverConfig, receiver return err } result.LineConfigs = append(result.LineConfigs, newNotifierConfig(receiver, cfg)) + case "mqtt": + cfg, err := mqtt.NewConfig(receiver.Settings, decryptFn) + if err != nil { + return err + } + result.MqttConfigs = append(result.MqttConfigs, newNotifierConfig(receiver, cfg)) case "opsgenie": cfg, err := opsgenie.NewConfig(receiver.Settings, decryptFn) if err != nil { diff --git a/notify/testing.go b/notify/testing.go index f1adb3da..a200afb7 100644 --- a/notify/testing.go +++ b/notify/testing.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/alerting/receivers/googlechat" "github.com/grafana/alerting/receivers/kafka" "github.com/grafana/alerting/receivers/line" + "github.com/grafana/alerting/receivers/mqtt" "github.com/grafana/alerting/receivers/opsgenie" "github.com/grafana/alerting/receivers/pagerduty" "github.com/grafana/alerting/receivers/pushover" @@ -145,6 +146,10 @@ var AllKnownConfigsForTesting = map[string]NotifierConfigTest{ Config: line.FullValidConfigForTesting, Secrets: line.FullValidSecretsForTesting, }, + "mqtt": {NotifierType: "mqtt", + Config: mqtt.FullValidConfigForTesting, + Secrets: mqtt.FullValidSecretsForTesting, + }, "opsgenie": {NotifierType: "opsgenie", Config: opsgenie.FullValidConfigForTesting, Secrets: opsgenie.FullValidSecretsForTesting, diff --git a/receivers/mqtt/config.go b/receivers/mqtt/config.go new file mode 100644 index 00000000..44b179cd --- /dev/null +++ b/receivers/mqtt/config.go @@ -0,0 +1,83 @@ +package mqtt + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type Config struct { + BrokerURL string `json:"brokerUrl,omitempty" yaml:"brokerUrl,omitempty"` + ClientID string `json:"clientId,omitempty" yaml:"clientId,omitempty"` + Topic string `json:"topic,omitempty" yaml:"topic,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty" yaml:"insecureSkipVerify,omitempty"` +} + +func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Config, error) { + var settings Config + err := json.Unmarshal(jsonData, &settings) + if err != nil { + return Config{}, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if settings.BrokerURL == "" { + return Config{}, errors.New("MQTT broker URL must be specified") + } + if _, err := isValidMqttURL(settings.BrokerURL); err != nil { + return Config{}, fmt.Errorf("Invalid MQTT broker URL: %w", err) + } + + if settings.Topic == "" { + return Config{}, errors.New("MQTT topic must be specified") + } + + if settings.ClientID == "" { + settings.ClientID = "Grafana" + } + + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + + password := decryptFn("password", settings.Password) + settings.Password = password + + return settings, nil +} + +func isValidMqttURL(mqttURL string) (bool, error) { + parsedURL, err := url.Parse(mqttURL) + if err != nil { + return false, err + } + + if parsedURL.Scheme != "tcp" && parsedURL.Scheme != "ssl" { + return false, errors.New("Invalid scheme, must be 'tcp' or 'ssl'") + } + + host := parsedURL.Host + if !strings.Contains(host, ":") { + return false, errors.New("Port must be specified") + } + + _, port, err := net.SplitHostPort(host) + if err != nil { + return false, err + } + + if portNum, err := strconv.ParseInt(port, 10, 32); err != nil || portNum > 65535 || portNum < 1 { + return false, errors.New("Port must be a valid number between 1 and 65535") + } + + return true, nil +} diff --git a/receivers/mqtt/config_test.go b/receivers/mqtt/config_test.go new file mode 100644 index 00000000..f8d6df12 --- /dev/null +++ b/receivers/mqtt/config_test.go @@ -0,0 +1,106 @@ +package mqtt + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + receiversTesting "github.com/grafana/alerting/receivers/testing" + "github.com/grafana/alerting/templates" +) + +func TestNewConfig(t *testing.T) { + cases := []struct { + name string + settings string + secureSettings map[string][]byte + expectedConfig Config + expectedInitError string + }{ + { + name: "Error if empty", + settings: "", + expectedInitError: `failed to unmarshal settings`, + }, + { + name: "Error if broker URL is missing", + settings: `{}`, + expectedInitError: `MQTT broker URL must be specified`, + }, + { + name: "Error if topic is missing", + settings: `{ "brokerUrl" : "tcp://localhost:1883" }`, + expectedInitError: `MQTT topic must be specified`, + }, + { + name: "Error if the broker URL does not have the scheme", + settings: `{ "brokerUrl" : "localhost" }`, + expectedInitError: `Invalid MQTT broker URL: Invalid scheme, must be 'tcp' or 'ssl'`, + }, + { + name: "Error if the broker URL has invalid scheme", + settings: `{ "brokerUrl" : "http://localhost" }`, + expectedInitError: `Invalid MQTT broker URL: Invalid scheme, must be 'tcp' or 'ssl'`, + }, + { + name: "Error if the broker URL does not have the port", + settings: `{ "brokerUrl" : "tcp://localhost" }`, + expectedInitError: `Invalid MQTT broker URL: Port must be specified`, + }, + { + name: "Error if the broker URL port is invalid", + settings: `{ "brokerUrl" : "tcp://localhost:100000" }`, + expectedInitError: `Invalid MQTT broker URL: Port must be a valid number between 1 and 65535`, + }, + { + name: "Minimal valid configuration", + settings: `{ "brokerUrl" : "tcp://localhost:1883", "topic": "grafana/alerts"}`, + expectedConfig: Config{ + Message: templates.DefaultMessageEmbed, + BrokerURL: "tcp://localhost:1883", + Topic: "grafana/alerts", + ClientID: "Grafana", + }, + }, + { + name: "Configuration with insecureSkipVerify", + settings: `{ "brokerUrl" : "tcp://localhost:1883", "topic": "grafana/alerts", "insecureSkipVerify": true}`, + expectedConfig: Config{ + Message: templates.DefaultMessageEmbed, + BrokerURL: "tcp://localhost:1883", + Topic: "grafana/alerts", + ClientID: "Grafana", + InsecureSkipVerify: true, + }, + }, + { + name: "Minimal valid configuration with secrets", + settings: `{ "brokerUrl" : "tcp://localhost:1883", "topic": "grafana/alerts", "username": "grafana"}`, + secureSettings: map[string][]byte{ + "password": []byte("testpasswd"), + }, + expectedConfig: Config{ + Message: templates.DefaultMessageEmbed, + BrokerURL: "tcp://localhost:1883", + Topic: "grafana/alerts", + ClientID: "Grafana", + Username: "grafana", + Password: "testpasswd", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + actual, err := NewConfig(json.RawMessage(c.settings), receiversTesting.DecryptForTesting(c.secureSettings)) + + if c.expectedInitError != "" { + require.ErrorContains(t, err, c.expectedInitError) + return + } + require.NoError(t, err) + require.Equal(t, c.expectedConfig, actual) + }) + } +} diff --git a/receivers/mqtt/mqtt.go b/receivers/mqtt/mqtt.go new file mode 100644 index 00000000..c9ce7f81 --- /dev/null +++ b/receivers/mqtt/mqtt.go @@ -0,0 +1,89 @@ +package mqtt + +import ( + "context" + "crypto/tls" + "fmt" + + mqttLib "github.com/eclipse/paho.mqtt.golang" + "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type Client interface { + Connect() mqttLib.Token + Publish(topic string, qos byte, retained bool, payload interface{}) mqttLib.Token + Disconnect(quiesce uint) +} + +type Notifier struct { + *receivers.Base + log logging.Logger + tmpl *templates.Template + settings Config + client Client +} + +func defaultClientFactory(opts *mqttLib.ClientOptions) Client { + return mqttLib.NewClient(opts) +} + +func New(cfg Config, meta receivers.Metadata, template *templates.Template, logger logging.Logger, clientFactory func(opts *mqttLib.ClientOptions) Client) *Notifier { + if clientFactory == nil { + clientFactory = defaultClientFactory + } + + opts := mqttLib.NewClientOptions(). + AddBroker(cfg.BrokerURL). + SetClientID(cfg.ClientID). + SetUsername(cfg.Username). + SetPassword(cfg.Password) + + if cfg.InsecureSkipVerify { + tlsCfg := tls.Config{ + InsecureSkipVerify: true, + } + opts.SetTLSConfig(&tlsCfg) + } + + return &Notifier{ + Base: receivers.NewBase(meta), + log: logger, + tmpl: template, + settings: cfg, + client: clientFactory(opts), + } +} + +func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + n.log.Debug("Sending an MQTT message") + + if token := n.client.Connect(); token.Wait() && token.Error() != nil { + n.log.Error("Failed to connect to MQTT broker", "error", token.Error()) + return false, fmt.Errorf("Failed to connect to MQTT broker: %w", token.Error()) + } + + var err error + tmpl, _ := templates.TmplText(ctx, n.tmpl, as, n.log, &err) + messageText := tmpl(n.settings.Message) + if err != nil { + n.log.Error("Failed to template MQTT message", "error", err) + return false, fmt.Errorf("Failed to template MQTT message: %w", err) + } + + if token := n.client.Publish(n.settings.Topic, 0, false, messageText); token.Wait() && token.Error() != nil { + n.log.Error("Failed to publish MQTT message", "error", token.Error()) + return false, fmt.Errorf("Failed to publish MQTT message: %w", token.Error()) + } + + n.client.Disconnect(250) + + return true, nil +} + +func (n *Notifier) SendResolved() bool { + return !n.GetDisableResolveMessage() +} diff --git a/receivers/mqtt/mqtt_test.go b/receivers/mqtt/mqtt_test.go new file mode 100644 index 00000000..c8c6de72 --- /dev/null +++ b/receivers/mqtt/mqtt_test.go @@ -0,0 +1,253 @@ +package mqtt + +import ( + "context" + "net/url" + "testing" + + mqttLib "github.com/eclipse/paho.mqtt.golang" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type mockMQTTClient struct { + mock.Mock + publishedMessages []*publishedMessage +} + +type publishedMessage struct { + topic string + message string +} + +func (m *mockMQTTClient) Publish(topic string, qos byte, retained bool, payload interface{}) mqttLib.Token { + args := m.Called(topic, qos, retained, payload) + + m.publishedMessages = append(m.publishedMessages, &publishedMessage{ + topic: topic, + message: payload.(string), + }) + + return args.Get(0).(mqttLib.Token) +} + +func (m *mockMQTTClient) Connect() mqttLib.Token { + args := m.Called() + return args.Get(0).(mqttLib.Token) +} + +// revive:disable:unused-parameter +func (m *mockMQTTClient) Disconnect(quiesce uint) {} + +// revive:enable:unused-parameter + +func TestNotify(t *testing.T) { + tmpl := templates.ForTests(t) + require.NotNil(t, tmpl) + + externalURL, err := url.Parse("http://localhost/base") + require.NoError(t, err) + tmpl.ExternalURL = externalURL + + cases := []struct { + name string + settings Config + alerts []*types.Alert + expMessage *publishedMessage + expError error + }{ + { + name: "A single alert with default template", + settings: Config{ + Topic: "alert1", + Message: templates.DefaultMessageEmbed, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + GeneratorURL: "a URL", + }, + }, + }, + expMessage: &publishedMessage{ + topic: "alert1", + message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/base/d/abcd\nPanel: http://localhost/base/d/abcd?viewPanel=efgh\n", + }, + expError: nil, + }, + { + name: "Multiple alerts with default template", + settings: Config{ + Topic: "grafana/alerts", + Message: templates.DefaultMessageEmbed, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + GeneratorURL: "URL 1", + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert2", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + GeneratorURL: "URL 2", + }, + }, + }, + expMessage: &publishedMessage{ + topic: "grafana/alerts", + message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: URL 1\nSilence: http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/base/d/abcd\nPanel: http://localhost/base/d/abcd?viewPanel=efgh\n\nValue: [no value]\nLabels:\n - alertname = alert2\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSource: URL 2\nSilence: http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert2&matcher=lbl1%3Dval2\nDashboard: http://localhost/base/d/abcd\nPanel: http://localhost/base/d/abcd?viewPanel=efgh\n", + }, + expError: nil, + }, + { + name: "Multiple alerts with custom template", + settings: Config{ + Topic: "grafana/alerts", + Message: `count: {{len .Alerts.Firing}}, firing: {{ template "__text_alert_list" .Alerts.Firing }}}`, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + GeneratorURL: "URL 1", + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert2", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + GeneratorURL: "URL 2", + }, + }, + }, + expMessage: &publishedMessage{ + topic: "grafana/alerts", + message: "count: 2, firing: \nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: URL 1\nSilence: http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/base/d/abcd\nPanel: http://localhost/base/d/abcd?viewPanel=efgh\n\nValue: [no value]\nLabels:\n - alertname = alert2\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSource: URL 2\nSilence: http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert2&matcher=lbl1%3Dval2\nDashboard: http://localhost/base/d/abcd\nPanel: http://localhost/base/d/abcd?viewPanel=efgh\n}", + }, + expError: nil, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mockMQTTClient := new(mockMQTTClient) + mockMQTTClient.On("Connect").Return(&mqttLib.DummyToken{}) + mockMQTTClient.On("Publish", mock.Anything, uint8(0), false, mock.Anything).Return(&mqttLib.DummyToken{}) + + n := &Notifier{ + Base: &receivers.Base{ + Name: "", + Type: "", + UID: "", + DisableResolveMessage: false, + }, + log: &logging.FakeLogger{}, + tmpl: tmpl, + settings: c.settings, + client: mockMQTTClient, + } + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + recoverableErr, err := n.Notify(ctx, c.alerts...) + if c.expError != nil { + assert.False(t, recoverableErr) + require.Error(t, err) + require.Equal(t, c.expError.Error(), err.Error()) + return + } + require.NoError(t, err) + + if c.expMessage != nil { + require.Equal(t, 1, len(mockMQTTClient.publishedMessages)) + require.Equal(t, c.expMessage, mockMQTTClient.publishedMessages[0]) + } + }) + } +} + +func TestNew(t *testing.T) { + tmpl := templates.ForTests(t) + require.NotNil(t, tmpl) + + cases := []struct { + name string + cfg Config + }{ + { + name: "Minimal configuration", + cfg: Config{ + Topic: "alerts", + Message: templates.DefaultMessageEmbed, + Username: "user", + Password: "pass", + BrokerURL: "tcp://127.0.0.1:1883", + ClientID: "test-grafana", + }, + }, + { + name: "Configuration with insecureSkipVerify", + cfg: Config{ + Topic: "alerts", + Message: templates.DefaultMessageEmbed, + Username: "user", + Password: "pass", + BrokerURL: "tcp://127.0.0.1:1883", + ClientID: "test-grafana", + InsecureSkipVerify: true, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var calledWithOpts mqttLib.ClientOptions + mockClientFactory := func(opts *mqttLib.ClientOptions) Client { + calledWithOpts = *opts + return &mockMQTTClient{} + } + + n := New( + tc.cfg, + receivers.Metadata{}, + tmpl, + &logging.FakeLogger{}, + mockClientFactory, + ) + + require.NotNil(t, n) + require.NotNil(t, n.Base) + require.NotNil(t, n.log) + require.NotNil(t, n.settings) + require.NotNil(t, n.client) + require.Equal(t, tmpl, n.tmpl) + + require.NotNil(t, calledWithOpts) + require.Equal(t, tc.cfg.Username, calledWithOpts.Username) + require.Equal(t, tc.cfg.Password, calledWithOpts.Password) + require.Equal(t, tc.cfg.BrokerURL, calledWithOpts.Servers[0].String()) + require.Equal(t, tc.cfg.ClientID, calledWithOpts.ClientID) + + if tc.cfg.InsecureSkipVerify { + require.True(t, calledWithOpts.TLSConfig.InsecureSkipVerify) + } else { + require.Nil(t, calledWithOpts.TLSConfig) + } + }) + } +} diff --git a/receivers/mqtt/testing.go b/receivers/mqtt/testing.go new file mode 100644 index 00000000..21c0b7b4 --- /dev/null +++ b/receivers/mqtt/testing.go @@ -0,0 +1,15 @@ +package mqtt + +// FullValidConfigForTesting is a string representation of a JSON object that contains all fields supported by the notifier Config. It can be used without secrets. +const FullValidConfigForTesting = `{ + "brokerUrl": "tcp://localhost:1883", + "topic": "grafana/alerts", + "clientId": "grafana-test-client-id", + "username": "test-username", + "insecureSkipVerify": false +}` + +// FullValidSecretsForTesting is a string representation of JSON object that contains all fields that can be overridden from secrets +const FullValidSecretsForTesting = `{ + "password": "test-password" +}`