Skip to content

Commit 172b54d

Browse files
Support for building Prometheus Alertmanager integrations (#339)
* extract receiver builders to functions to reuse in Mimir * add conversion logic for ClientOptions to upstream * add support for prometheus integrations
1 parent 6bf6277 commit 172b54d

File tree

7 files changed

+730
-49
lines changed

7 files changed

+730
-49
lines changed

go.mod

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ require (
3535
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
3636
github.com/davecgh/go-spew v1.1.1 // indirect
3737
github.com/docker/go-units v0.5.0 // indirect
38+
github.com/felixge/httpsnoop v1.0.4 // indirect
3839
github.com/go-logfmt/logfmt v0.5.1 // indirect
3940
github.com/go-logr/logr v1.4.2 // indirect
4041
github.com/go-logr/stdr v1.2.2 // indirect
@@ -57,7 +58,6 @@ require (
5758
github.com/hashicorp/go-msgpack v0.5.5 // indirect
5859
github.com/hashicorp/go-multierror v1.1.1 // indirect
5960
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
60-
github.com/hashicorp/go-uuid v1.0.1 // indirect
6161
github.com/hashicorp/golang-lru v0.5.4 // indirect
6262
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
6363
github.com/hashicorp/memberlist v0.5.0 // indirect
@@ -87,9 +87,11 @@ require (
8787
github.com/shopspring/decimal v1.2.0 // indirect
8888
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
8989
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect
90-
github.com/spf13/cast v1.3.1 // indirect
90+
github.com/spf13/cast v1.5.0 // indirect
9191
github.com/stretchr/objx v0.5.2 // indirect
9292
go.mongodb.org/mongo-driver v1.13.1 // indirect
93+
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.55.0 // indirect
94+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
9395
go.opentelemetry.io/otel v1.30.0 // indirect
9496
go.opentelemetry.io/otel/metric v1.30.0 // indirect
9597
go.opentelemetry.io/otel/trace v1.30.0 // indirect
@@ -102,6 +104,7 @@ require (
102104
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
103105
google.golang.org/protobuf v1.34.2 // indirect
104106
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
107+
gopkg.in/telebot.v3 v3.2.1 // indirect
105108
gopkg.in/yaml.v2 v2.4.0 // indirect
106109
)
107110

go.sum

Lines changed: 357 additions & 1 deletion
Large diffs are not rendered by default.

http/client.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ import (
1515
"github.com/benbjohnson/clock"
1616
"github.com/go-kit/log"
1717
"github.com/go-kit/log/level"
18+
commoncfg "github.com/prometheus/common/config"
1819

1920
"github.com/grafana/alerting/receivers"
2021
)
2122

2223
var ErrInvalidMethod = errors.New("webhook only supports HTTP methods PUT or POST")
2324

2425
type clientConfiguration struct {
25-
userAgent string
26-
dialer net.Dialer // We use Dialer here instead of DialContext as our mqtt client doesn't support DialContext.
26+
userAgent string
27+
dialer net.Dialer // We use Dialer here instead of DialContext as our mqtt client doesn't support DialContext.
28+
customDialer bool
2729
}
2830

2931
// defaultDialTimeout is the default timeout for the dialer, 30 seconds to match http.DefaultTransport.
@@ -63,9 +65,28 @@ func WithUserAgent(userAgent string) ClientOption {
6365
func WithDialer(dialer net.Dialer) ClientOption {
6466
return func(c *clientConfiguration) {
6567
c.dialer = dialer
68+
c.customDialer = true
6669
}
6770
}
6871

72+
func ToHTTPClientOption(option ...ClientOption) []commoncfg.HTTPClientOption {
73+
cfg := clientConfiguration{}
74+
for _, opt := range option {
75+
if opt == nil {
76+
continue
77+
}
78+
opt(&cfg)
79+
}
80+
result := make([]commoncfg.HTTPClientOption, 0, len(option))
81+
if cfg.userAgent != "" {
82+
result = append(result, commoncfg.WithUserAgent(cfg.userAgent))
83+
}
84+
if cfg.customDialer {
85+
result = append(result, commoncfg.WithDialContextFunc(cfg.dialer.DialContext))
86+
}
87+
return result
88+
}
89+
6990
func (ns *Client) SendWebhook(ctx context.Context, l log.Logger, webhook *receivers.SendWebhookSettings) error {
7091
// This method was moved from https://github.com/grafana/grafana/blob/71d04a326be9578e2d678f23c1efa61768e0541f/pkg/services/notifications/webhook.go#L38
7192
if webhook.HTTPMethod == "" {

http/client_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net"
77
"net/http"
88
"net/http/httptest"
9+
"reflect"
910
"testing"
1011
"time"
1112

@@ -190,3 +191,24 @@ func TestSendWebhookHMAC(t *testing.T) {
190191
require.NotEmpty(t, timestamp)
191192
})
192193
}
194+
195+
func TestToHTTPClientOption(t *testing.T) {
196+
// this test guards against adding new fields to the configuration structure without updating the conversion function
197+
t.Run("empty converts to empty", func(t *testing.T) {
198+
require.Empty(t, ToHTTPClientOption())
199+
require.Empty(t, ToHTTPClientOption(nil))
200+
})
201+
202+
var f ClientOption = func(configuration *clientConfiguration) {
203+
configuration.userAgent = "test"
204+
configuration.dialer = net.Dialer{Timeout: 5 * time.Second}
205+
configuration.customDialer = true
206+
}
207+
actual := ToHTTPClientOption(f)
208+
require.Len(t, actual, 2)
209+
210+
// Verify number of fields using reflection
211+
tp := reflect.TypeOf(clientConfiguration{})
212+
// You need to increase the number of fields covered in this test, if you add a new field to the configuration struct.
213+
require.Equalf(t, 3, tp.NumField(), "Not all fields are converted to HTTPClientOption, which means that the configuration will not be supported in upstream integrations")
214+
}

notify/factory.go

Lines changed: 195 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
package notify
22

33
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
48
"github.com/go-kit/log"
9+
"github.com/go-kit/log/level"
10+
"github.com/prometheus/alertmanager/config"
511
"github.com/prometheus/alertmanager/notify"
12+
"github.com/prometheus/alertmanager/types"
13+
commoncfg "github.com/prometheus/common/config"
14+
15+
promDiscord "github.com/prometheus/alertmanager/notify/discord"
16+
promEmail "github.com/prometheus/alertmanager/notify/email"
17+
promMsteams "github.com/prometheus/alertmanager/notify/msteams"
18+
promOpsgenie "github.com/prometheus/alertmanager/notify/opsgenie"
19+
promPagerduty "github.com/prometheus/alertmanager/notify/pagerduty"
20+
promPushover "github.com/prometheus/alertmanager/notify/pushover"
21+
promSlack "github.com/prometheus/alertmanager/notify/slack"
22+
promSns "github.com/prometheus/alertmanager/notify/sns"
23+
promTelegram "github.com/prometheus/alertmanager/notify/telegram"
24+
promVictorops "github.com/prometheus/alertmanager/notify/victorops"
25+
promWebex "github.com/prometheus/alertmanager/notify/webex"
26+
promWebhook "github.com/prometheus/alertmanager/notify/webhook"
27+
promWechat "github.com/prometheus/alertmanager/notify/wechat"
28+
"github.com/prometheus/alertmanager/template"
629

730
"github.com/grafana/alerting/http"
831
"github.com/grafana/alerting/images"
32+
"github.com/grafana/alerting/notify/nfstatus"
933
"github.com/grafana/alerting/receivers"
1034
"github.com/grafana/alerting/receivers/alertmanager"
1135
"github.com/grafana/alerting/receivers/dinding"
@@ -35,9 +59,11 @@ import (
3559

3660
type WrapNotifierFunc func(integrationName string, notifier notify.Notifier) notify.Notifier
3761

38-
// BuildReceiverIntegrations creates integrations for each configured notification channel in GrafanaReceiverConfig.
62+
var NoWrap WrapNotifierFunc = func(_ string, notifier notify.Notifier) notify.Notifier { return notifier }
63+
64+
// BuildGrafanaReceiverIntegrations creates integrations for each configured notification channel in GrafanaReceiverConfig.
3965
// It returns a slice of Integration objects, one for each notification channel, along with any errors that occurred.
40-
func BuildReceiverIntegrations(
66+
func BuildGrafanaReceiverIntegrations(
4167
receiver GrafanaReceiverConfig,
4268
tmpl *templates.Template,
4369
img images.Provider,
@@ -133,3 +159,170 @@ func BuildReceiverIntegrations(
133159
}
134160
return integrations
135161
}
162+
163+
// BuildPrometheusReceiverIntegrations builds a list of integration notifiers off of a receiver config.
164+
// Taken from https://github.com/grafana/mimir/blob/fa489e696481fe0b7b97598077565dc5027afa84/pkg/alertmanager/alertmanager.go#L754
165+
// which is taken from https://github.com/prometheus/alertmanager/blob/94d875f1227b29abece661db1a68c001122d1da5/cmd/alertmanager/main.go#L112-L159.
166+
func BuildPrometheusReceiverIntegrations(
167+
nc config.Receiver,
168+
tmplProvider TemplatesProvider,
169+
httpClientOptions []http.ClientOption,
170+
logger log.Logger,
171+
wrapper WrapNotifierFunc,
172+
) ([]*nfstatus.Integration, error) {
173+
var (
174+
errs types.MultiError
175+
integrations []*nfstatus.Integration
176+
tmpl *template.Template
177+
httpOps []commoncfg.HTTPClientOption
178+
initOnce = sync.OnceFunc(func() { // lazy evaluate template so we do not create one if we don't need it
179+
httpOps = http.ToHTTPClientOption(httpClientOptions...)
180+
t, err := tmplProvider.GetTemplate(templates.MimirKind)
181+
if err != nil {
182+
errs.Add(err)
183+
return
184+
}
185+
tmpl = t
186+
})
187+
add = func(name string, i int, rs notify.ResolvedSender, f func(l log.Logger) (notify.Notifier, error)) {
188+
initOnce()
189+
n, err := f(log.With(logger, "integration", name))
190+
if err != nil {
191+
errs.Add(err)
192+
return
193+
}
194+
if wrapper != nil {
195+
n = wrapper(name, n)
196+
}
197+
integrations = append(integrations, nfstatus.NewIntegration(n, rs, name, i, nc.Name))
198+
}
199+
)
200+
201+
for i, c := range nc.WebhookConfigs {
202+
add("webhook", i, c, func(l log.Logger) (notify.Notifier, error) { return promWebhook.New(c, tmpl, l, httpOps...) })
203+
}
204+
for i, c := range nc.EmailConfigs {
205+
add("email", i, c, func(l log.Logger) (notify.Notifier, error) { return promEmail.New(c, tmpl, l), nil })
206+
}
207+
for i, c := range nc.PagerdutyConfigs {
208+
add("pagerduty", i, c, func(l log.Logger) (notify.Notifier, error) { return promPagerduty.New(c, tmpl, l, httpOps...) })
209+
}
210+
for i, c := range nc.OpsGenieConfigs {
211+
add("opsgenie", i, c, func(l log.Logger) (notify.Notifier, error) { return promOpsgenie.New(c, tmpl, l, httpOps...) })
212+
}
213+
for i, c := range nc.WechatConfigs {
214+
add("wechat", i, c, func(l log.Logger) (notify.Notifier, error) { return promWechat.New(c, tmpl, l, httpOps...) })
215+
}
216+
for i, c := range nc.SlackConfigs {
217+
add("slack", i, c, func(l log.Logger) (notify.Notifier, error) { return promSlack.New(c, tmpl, l, httpOps...) })
218+
}
219+
for i, c := range nc.VictorOpsConfigs {
220+
add("victorops", i, c, func(l log.Logger) (notify.Notifier, error) { return promVictorops.New(c, tmpl, l, httpOps...) })
221+
}
222+
for i, c := range nc.PushoverConfigs {
223+
add("pushover", i, c, func(l log.Logger) (notify.Notifier, error) { return promPushover.New(c, tmpl, l, httpOps...) })
224+
}
225+
for i, c := range nc.SNSConfigs {
226+
add("sns", i, c, func(l log.Logger) (notify.Notifier, error) { return promSns.New(c, tmpl, l, httpOps...) })
227+
}
228+
for i, c := range nc.TelegramConfigs {
229+
add("telegram", i, c, func(l log.Logger) (notify.Notifier, error) { return promTelegram.New(c, tmpl, l, httpOps...) })
230+
}
231+
for i, c := range nc.DiscordConfigs {
232+
add("discord", i, c, func(l log.Logger) (notify.Notifier, error) { return promDiscord.New(c, tmpl, l, httpOps...) })
233+
}
234+
for i, c := range nc.WebexConfigs {
235+
add("webex", i, c, func(l log.Logger) (notify.Notifier, error) { return promWebex.New(c, tmpl, l, httpOps...) })
236+
}
237+
for i, c := range nc.MSTeamsConfigs {
238+
add("msteams", i, c, func(l log.Logger) (notify.Notifier, error) { return promMsteams.New(c, tmpl, l, httpOps...) })
239+
}
240+
// If we add support for more integrations, we need to add them to validation as well. See validation.allowedIntegrationNames field.
241+
if errs.Len() > 0 {
242+
return nil, &errs
243+
}
244+
return integrations, nil
245+
}
246+
247+
// BuildReceiversIntegrations builds integrations for the provided API receivers and returns them mapped by receiver name.
248+
// It ensures uniqueness of receivers by the name, overwriting duplicates and logs warnings.
249+
// Returns an error if any integration fails during its construction.
250+
func BuildReceiversIntegrations(
251+
tenantID int64,
252+
apiReceivers []*APIReceiver,
253+
templ TemplatesProvider,
254+
images images.Provider,
255+
decryptFn GetDecryptedValueFn,
256+
emailSender receivers.EmailSender,
257+
httpClientOptions []http.ClientOption,
258+
notifierFunc WrapNotifierFunc,
259+
version string,
260+
logger log.Logger,
261+
) (map[string][]*Integration, error) {
262+
nameToReceiver := make(map[string]*APIReceiver, len(apiReceivers))
263+
for _, receiver := range apiReceivers {
264+
if existing, ok := nameToReceiver[receiver.Name]; ok {
265+
itypes := make([]string, 0, len(existing.GrafanaIntegrations.Integrations))
266+
for _, i := range existing.GrafanaIntegrations.Integrations {
267+
itypes = append(itypes, i.Type)
268+
}
269+
level.Warn(logger).Log("msg", "receiver with same name is defined multiple times. Only the last one will be used", "receiver_name", receiver.Name, "overwritten_integrations", itypes)
270+
}
271+
nameToReceiver[receiver.Name] = receiver
272+
}
273+
274+
integrationsMap := make(map[string][]*Integration, len(apiReceivers))
275+
for name, apiReceiver := range nameToReceiver {
276+
integrations, err := BuildReceiverIntegrations(tenantID, apiReceiver, templ, images, decryptFn, emailSender, httpClientOptions, notifierFunc, version, logger)
277+
if err != nil {
278+
return nil, fmt.Errorf("failed to build receiver %s: %w", name, err)
279+
}
280+
integrationsMap[name] = integrations
281+
}
282+
return integrationsMap, nil
283+
}
284+
285+
// BuildReceiverIntegrations builds integrations for the provided API receiver and returns them.
286+
// It supports both Prometheus and Grafana integrations and ensures that both of them use only templates dedicated for the kind.
287+
func BuildReceiverIntegrations(
288+
tenantID int64,
289+
receiver *APIReceiver,
290+
tmpls TemplatesProvider,
291+
images images.Provider,
292+
decryptFn GetDecryptedValueFn,
293+
emailSender receivers.EmailSender,
294+
httpClientOptions []http.ClientOption,
295+
wrapNotifierFunc WrapNotifierFunc,
296+
version string,
297+
logger log.Logger,
298+
) ([]*Integration, error) {
299+
var integrations []*Integration
300+
if len(receiver.Integrations) > 0 {
301+
receiverCfg, err := BuildReceiverConfiguration(context.Background(), receiver, DecodeSecretsFromBase64, decryptFn)
302+
if err != nil {
303+
return nil, err
304+
}
305+
tmpl, err := tmpls.GetTemplate(templates.GrafanaKind)
306+
if err != nil {
307+
return nil, err
308+
}
309+
integrations = BuildGrafanaReceiverIntegrations(
310+
receiverCfg,
311+
tmpl,
312+
images,
313+
logger,
314+
emailSender,
315+
wrapNotifierFunc,
316+
tenantID,
317+
version,
318+
httpClientOptions...,
319+
)
320+
}
321+
mimir, err := BuildPrometheusReceiverIntegrations(receiver.ConfigReceiver, tmpls, httpClientOptions, logger, wrapNotifierFunc)
322+
if err != nil {
323+
return nil, err
324+
}
325+
integrations = append(integrations, mimir...)
326+
327+
return integrations, nil
328+
}

0 commit comments

Comments
 (0)