Files
grafana/pkg/services/ngalert/sender/sender_test.go
Yuri Tseretyan 7ba2c559c4 Alerting: Add support for client certificate authentication and TLS options to External Alertmanager (#115716)
* add support for skip TLS verify

* extract constructor for ExternalAMcfg and tests

* extract constructor for AlertmanagerConfig and tests

* add support for client cert auth
2026-01-05 16:19:29 +00:00

470 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sender
import (
"fmt"
"testing"
"time"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/client_golang/prometheus"
common_config "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/config"
"github.com/prometheus/prometheus/discovery"
"github.com/prometheus/prometheus/model/labels"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
)
func TestSanitizeLabelName(t *testing.T) {
cases := []struct {
desc string
labelName string
expectedResult string
expectedErr string
}{
{
desc: "Remove whitespace",
labelName: " a\tb\nc\vd\re\ff ",
expectedResult: "abcdef",
},
{
desc: "Replace ASCII with underscore",
labelName: " !\"#$%&\\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
expectedResult: "________________0123456789_______ABCDEFGHIJKLMNOPQRSTUVWXYZ______abcdefghijklmnopqrstuvwxyz____",
},
{
desc: "Replace non-ASCII unicode with hex",
labelName: "_€_ƒ_„_†_‡_œ_Ÿ_®_º_¼_×_ð_þ_¿_±_四_十_二_🔥",
expectedResult: "_0x20ac_0x192_0x201e_0x2020_0x2021_0x153_0x178_0xae_0xba_0xbc_0xd7_0xf0_0xfe_0xbf_0xb1_0x56db_0x5341_0x4e8c_0x1f525",
},
{ // labels starting with a number are invalid, so we have to make sure we don't sanitize to another invalid label.
desc: "If first character is replaced with hex, prefix with underscore",
labelName: "😍😍😍",
expectedResult: "_0x1f60d0x1f60d0x1f60d",
},
{
desc: "Empty string should error",
labelName: "",
expectedErr: "label name cannot be empty",
},
{
desc: "Only whitespace should error",
labelName: " \t\n\v\n\f ",
expectedErr: "label name is empty after removing invalids chars",
},
}
for _, tc := range cases {
logger := log.New("ngalert.sender.external-alertmanager")
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry())
require.NoError(t, err)
t.Run(tc.desc, func(t *testing.T) {
res, err := am.sanitizeLabelName(tc.labelName)
if tc.expectedErr != "" {
require.EqualError(t, err, tc.expectedErr)
}
require.Equal(t, tc.expectedResult, res)
})
}
}
func TestSanitizeLabelSet(t *testing.T) {
cases := []struct {
desc string
labelset models.LabelSet
expectedResult labels.Labels
}{
{
desc: "Duplicate labels after sanitizations, append short has as suffix to duplicates",
labelset: models.LabelSet{
"test-alert": "42",
"test_alert": "43",
"test+alert": "44",
},
expectedResult: labels.Labels{
labels.Label{Name: "test_alert", Value: "44"},
labels.Label{Name: "test_alert_ed6237", Value: "42"},
labels.Label{Name: "test_alert_a67b5e", Value: "43"},
},
},
{
desc: "If sanitize fails for a label, skip it",
labelset: models.LabelSet{
"test-alert": "42",
" \t\n\v\n\f ": "43",
"test+alert": "44",
},
expectedResult: labels.Labels{
labels.Label{Name: "test_alert", Value: "44"},
labels.Label{Name: "test_alert_ed6237", Value: "42"},
},
},
}
for _, tc := range cases {
logger := log.New("ngalert.sender.external-alertmanager")
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry())
require.NoError(t, err)
t.Run(tc.desc, func(t *testing.T) {
require.Equal(t, tc.expectedResult, am.sanitizeLabelSet(tc.labelset))
})
}
}
func TestWithMaxQueueCapacity(t *testing.T) {
logger := log.NewNopLogger()
t.Run("WithMaxQueueCapacity sets custom capacity", func(t *testing.T) {
customCapacity := 123
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxQueueCapacity(customCapacity))
require.NoError(t, err)
require.Equal(t, customCapacity, am.options.QueueCapacity)
})
t.Run("default capacity when option is not used", func(t *testing.T) {
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry())
require.NoError(t, err)
require.Equal(t, defaultMaxQueueCapacity, am.options.QueueCapacity)
})
t.Run("custom queue capacity is enforced", func(t *testing.T) {
customCapacity := 5
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxQueueCapacity(customCapacity))
require.NoError(t, err)
totalAlerts := customCapacity + 3
alerts := make([]*Alert, totalAlerts)
for i := range alerts {
alerts[i] = &Alert{
Labels: labels.FromStrings("alertname", fmt.Sprintf("alert_%d", i)),
}
}
am.manager.Send(alerts...)
require.Equal(t, customCapacity, len(am.manager.queue))
for i, alert := range am.manager.queue {
expectedLabel := fmt.Sprintf("alert_%d", i+3)
require.Equal(t, expectedLabel, alert.Labels.Get("alertname"))
}
})
}
func TestWithMaxBatchSize(t *testing.T) {
logger := log.NewNopLogger()
t.Run("WithMaxBatchSize sets custom batch size", func(t *testing.T) {
customBatchSize := 5
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxBatchSize(customBatchSize))
require.NoError(t, err)
require.Equal(t, customBatchSize, am.options.MaxBatchSize)
require.Equal(t, customBatchSize, am.manager.opts.MaxBatchSize)
})
t.Run("default batch size when option is not used", func(t *testing.T) {
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry())
require.NoError(t, err)
require.Equal(t, DefaultMaxBatchSize, am.options.MaxBatchSize)
require.Equal(t, DefaultMaxBatchSize, am.manager.opts.MaxBatchSize)
})
t.Run("custom batch size is enforced", func(t *testing.T) {
customBatchSize := 3
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxBatchSize(customBatchSize))
require.NoError(t, err)
totalAlerts := customBatchSize * 2
alerts := make([]*Alert, totalAlerts)
for i := range alerts {
alerts[i] = &Alert{
Labels: labels.FromStrings("alertname", fmt.Sprintf("alert_%d", i)),
}
}
am.manager.Send(alerts...)
require.Equal(t, totalAlerts, len(am.manager.queue))
firstBatch := am.manager.nextBatch()
require.Equal(t, customBatchSize, len(firstBatch))
secondBatch := am.manager.nextBatch()
require.Equal(t, customBatchSize, len(secondBatch))
emptyBatch := am.manager.nextBatch()
require.Equal(t, 0, len(emptyBatch), "No more alerts should remain")
})
}
func TestWithUTF8Labels(t *testing.T) {
logger := log.NewNopLogger()
alert := models.PostableAlert{
Annotations: models.LabelSet{
"some-name": "test",
},
Alert: models.Alert{
Labels: models.LabelSet{
"🔥": "fire",
},
},
}
t.Run("WithUTF8Labels preserves UTF-8 characters", func(t *testing.T) {
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithUTF8Labels())
require.NoError(t, err)
result := am.alertToNotifierAlert(alert)
require.Equal(t, "test", result.Annotations.Get("some-name"))
require.Equal(t, "fire", result.Labels.Get("🔥"))
})
t.Run("default sanitizes UTF-8 characters", func(t *testing.T) {
am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry())
require.NoError(t, err)
result := am.alertToNotifierAlert(alert)
require.Equal(t, "test", result.Annotations.Get("some_name"))
require.Equal(t, "fire", result.Labels.Get("_0x1f525"))
})
}
func TestExternalAMcfgToAlertmanagerConfig(t *testing.T) {
tests := []struct {
name string
cfg ExternalAMcfg
expected *config.AlertmanagerConfig
expectError bool
}{
{
name: "basic configuration without TLS skip verify",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093/alertmanager",
InsecureSkipVerify: false,
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "/alertmanager",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
},
expectError: false,
},
{
name: "configuration with TLS skip verify enabled",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
InsecureSkipVerify: true,
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
HTTPClientConfig: common_config.HTTPClientConfig{
TLSConfig: common_config.TLSConfig{
InsecureSkipVerify: true,
},
},
},
expectError: false,
},
{
name: "configuration with basic auth in URL",
cfg: ExternalAMcfg{
URL: "https://user:password@alertmanager.example.com:9093",
InsecureSkipVerify: false,
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
HTTPClientConfig: common_config.HTTPClientConfig{
BasicAuth: &common_config.BasicAuth{
Username: "user",
Password: "password",
},
},
},
expectError: false,
},
{
name: "configuration with basic auth and TLS skip verify",
cfg: ExternalAMcfg{
URL: "https://user:password@alertmanager.example.com:9093",
InsecureSkipVerify: true,
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
HTTPClientConfig: common_config.HTTPClientConfig{
BasicAuth: &common_config.BasicAuth{
Username: "user",
Password: "password",
},
TLSConfig: common_config.TLSConfig{
InsecureSkipVerify: true,
},
},
},
expectError: false,
},
{
name: "configuration with custom timeout",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
Timeout: 30 * time.Second,
InsecureSkipVerify: false,
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(30 * time.Second),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
},
expectError: false,
},
{
name: "invalid URL should return error",
cfg: ExternalAMcfg{
URL: "://invalid-url",
InsecureSkipVerify: false,
},
expected: nil,
expectError: true,
},
{
name: "configuration with TLS client auth",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
TLSClientCert: "client-cert-content",
TLSClientKey: "client-key-content",
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
HTTPClientConfig: common_config.HTTPClientConfig{
TLSConfig: common_config.TLSConfig{
Cert: "client-cert-content",
Key: "client-key-content",
},
},
},
expectError: false,
},
{
name: "configuration with TLS client auth and skip verify",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
InsecureSkipVerify: true,
TLSClientCert: "client-cert-content",
TLSClientKey: "client-key-content",
},
expected: &config.AlertmanagerConfig{
APIVersion: config.AlertmanagerAPIVersionV2,
Scheme: "https",
PathPrefix: "",
Timeout: model.Duration(defaultTimeout),
ServiceDiscoveryConfigs: discovery.Configs{
discovery.StaticConfig{
{
Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}},
},
},
},
HTTPClientConfig: common_config.HTTPClientConfig{
TLSConfig: common_config.TLSConfig{
InsecureSkipVerify: true,
Cert: "client-cert-content",
Key: "client-key-content",
},
},
},
expectError: false,
},
{
name: "TLS client cert provided but key missing - should error",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
TLSClientCert: "client-cert-content",
},
expected: nil,
expectError: true,
},
{
name: "TLS client key provided but cert missing - should error",
cfg: ExternalAMcfg{
URL: "https://alertmanager.example.com:9093",
TLSClientKey: "client-key-content",
},
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
amConfig, err := externalAMcfgToAlertmanagerConfig(tt.cfg)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.expected, amConfig)
})
}
}