diff --git a/config/config.go b/config/config.go index b8e484130a..0e2c25aef4 100644 --- a/config/config.go +++ b/config/config.go @@ -271,6 +271,9 @@ func resolveFilepaths(baseDir string, cfg *Config) { for _, cfg := range receiver.WebhookConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } + for _, cfg := range receiver.WebHookTemplateConfigs { + cfg.HTTPConfig.SetDirectory(baseDir) + } for _, cfg := range receiver.WechatConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } @@ -412,6 +415,11 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { wh.HTTPConfig = c.Global.HTTPConfig } } + for _, wh := range rcv.WebHookTemplateConfigs { + if wh.HTTPConfig == nil { + wh.HTTPConfig = c.Global.HTTPConfig + } + } for _, ec := range rcv.EmailConfigs { if ec.TLSConfig == nil { ec.TLSConfig = c.Global.SMTPTLSConfig @@ -1010,22 +1018,23 @@ type Receiver struct { // A unique identifier for this receiver. Name string `yaml:"name" json:"name"` - DiscordConfigs []*DiscordConfig `yaml:"discord_configs,omitempty" json:"discord_configs,omitempty"` - EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"` - PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"` - SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"` - WebhookConfigs []*WebhookConfig `yaml:"webhook_configs,omitempty" json:"webhook_configs,omitempty"` - OpsGenieConfigs []*OpsGenieConfig `yaml:"opsgenie_configs,omitempty" json:"opsgenie_configs,omitempty"` - WechatConfigs []*WechatConfig `yaml:"wechat_configs,omitempty" json:"wechat_configs,omitempty"` - PushoverConfigs []*PushoverConfig `yaml:"pushover_configs,omitempty" json:"pushover_configs,omitempty"` - VictorOpsConfigs []*VictorOpsConfig `yaml:"victorops_configs,omitempty" json:"victorops_configs,omitempty"` - SNSConfigs []*SNSConfig `yaml:"sns_configs,omitempty" json:"sns_configs,omitempty"` - TelegramConfigs []*TelegramConfig `yaml:"telegram_configs,omitempty" json:"telegram_configs,omitempty"` - WebexConfigs []*WebexConfig `yaml:"webex_configs,omitempty" json:"webex_configs,omitempty"` - MSTeamsConfigs []*MSTeamsConfig `yaml:"msteams_configs,omitempty" json:"msteams_configs,omitempty"` - MSTeamsV2Configs []*MSTeamsV2Config `yaml:"msteamsv2_configs,omitempty" json:"msteamsv2_configs,omitempty"` - JiraConfigs []*JiraConfig `yaml:"jira_configs,omitempty" json:"jira_configs,omitempty"` - RocketchatConfigs []*RocketchatConfig `yaml:"rocketchat_configs,omitempty" json:"rocketchat_configs,omitempty"` + DiscordConfigs []*DiscordConfig `yaml:"discord_configs,omitempty" json:"discord_configs,omitempty"` + EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"` + PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"` + SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"` + WebhookConfigs []*WebhookConfig `yaml:"webhook_configs,omitempty" json:"webhook_configs,omitempty"` + WebHookTemplateConfigs []*WebhookTemplateConfig `yaml:"webhook_template_configs,omitempty" json:"webhook_template_configs,omitempty"` + OpsGenieConfigs []*OpsGenieConfig `yaml:"opsgenie_configs,omitempty" json:"opsgenie_configs,omitempty"` + WechatConfigs []*WechatConfig `yaml:"wechat_configs,omitempty" json:"wechat_configs,omitempty"` + PushoverConfigs []*PushoverConfig `yaml:"pushover_configs,omitempty" json:"pushover_configs,omitempty"` + VictorOpsConfigs []*VictorOpsConfig `yaml:"victorops_configs,omitempty" json:"victorops_configs,omitempty"` + SNSConfigs []*SNSConfig `yaml:"sns_configs,omitempty" json:"sns_configs,omitempty"` + TelegramConfigs []*TelegramConfig `yaml:"telegram_configs,omitempty" json:"telegram_configs,omitempty"` + WebexConfigs []*WebexConfig `yaml:"webex_configs,omitempty" json:"webex_configs,omitempty"` + MSTeamsConfigs []*MSTeamsConfig `yaml:"msteams_configs,omitempty" json:"msteams_configs,omitempty"` + MSTeamsV2Configs []*MSTeamsV2Config `yaml:"msteamsv2_configs,omitempty" json:"msteamsv2_configs,omitempty"` + JiraConfigs []*JiraConfig `yaml:"jira_configs,omitempty" json:"jira_configs,omitempty"` + RocketchatConfigs []*RocketchatConfig `yaml:"rocketchat_configs,omitempty" json:"rocketchat_configs,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for Receiver. diff --git a/config/notifiers.go b/config/notifiers.go index 87f806aa27..9e6c1bf9e3 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -35,6 +35,13 @@ var ( }, } + // DefaultWebhookTemplateConfig defines default values for Webhook template configurations. + DefaultWebhookTemplateConfig = WebhookTemplateConfig{ + NotifierConfig: NotifierConfig{ + VSendResolved: true, + }, + } + // DefaultWebexConfig defines default values for Webex configurations. DefaultWebexConfig = WebexConfig{ NotifierConfig: NotifierConfig{ @@ -557,6 +564,39 @@ func (c *WebhookConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +// WebhookTemplateConfig configures notifications via a generic webhook. +type WebhookTemplateConfig struct { + NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + // URL to send POST request to. + URL *SecretURL `yaml:"url" json:"url"` + URLFile string `yaml:"url_file" json:"url_file"` + + Template string `yaml:"template,omitempty" json:"template,omitempty"` + + // Timeout is the maximum time allowed to invoke the webhook. Setting this to 0 + // does not impose a timeout. + Timeout time.Duration `yaml:"timeout" json:"timeout"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *WebhookTemplateConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultWebhookTemplateConfig + type plain WebhookTemplateConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.URL == nil && c.URLFile == "" { + return errors.New("one of url or url_file must be configured") + } + if c.URL != nil && c.URLFile != "" { + return errors.New("at most one of url & url_file must be configured") + } + return nil +} + // WechatConfig configures notifications via Wechat. type WechatConfig struct { NotifierConfig `yaml:",inline" json:",inline"` diff --git a/config/receiver/receiver.go b/config/receiver/receiver.go index d92a19a4c5..49be74c8bf 100644 --- a/config/receiver/receiver.go +++ b/config/receiver/receiver.go @@ -36,6 +36,7 @@ import ( "github.com/prometheus/alertmanager/notify/victorops" "github.com/prometheus/alertmanager/notify/webex" "github.com/prometheus/alertmanager/notify/webhook" + "github.com/prometheus/alertmanager/notify/webhook_template" "github.com/prometheus/alertmanager/notify/wechat" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" @@ -64,6 +65,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg for i, c := range nc.WebhookConfigs { add("webhook", i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, httpOpts...) }) } + for i, c := range nc.WebHookTemplateConfigs { + add("webhook_template", i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook_template.New(c, tmpl, l, httpOpts...) }) + } for i, c := range nc.EmailConfigs { add("email", i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil }) } diff --git a/config/receiver/receiver_test.go b/config/receiver/receiver_test.go index 3d146a98d0..4295cd5892 100644 --- a/config/receiver/receiver_test.go +++ b/config/receiver/receiver_test.go @@ -68,21 +68,58 @@ func TestBuildReceiverIntegrations(t *testing.T) { }, err: true, }, + { + receiver: config.Receiver{ + Name: "foo", + WebHookTemplateConfigs: []*config.WebhookTemplateConfig{ + { + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + { + HTTPConfig: &commoncfg.HTTPClientConfig{}, + NotifierConfig: config.NotifierConfig{ + VSendResolved: true, + }, + }, + }, + }, + exp: []notify.Integration{ + notify.NewIntegration(nil, sendResolved(false), "webhook_template", 0, "foo"), + notify.NewIntegration(nil, sendResolved(true), "webhook_template", 1, "foo"), + }, + }, + { + receiver: config.Receiver{ + Name: "foo", + WebHookTemplateConfigs: []*config.WebhookTemplateConfig{ + { + HTTPConfig: &commoncfg.HTTPClientConfig{ + TLSConfig: commoncfg.TLSConfig{ + CAFile: "not_existing", + }, + }, + }, + }, + }, + err: true, + }, } { tc := tc - t.Run("", func(t *testing.T) { - integrations, err := BuildReceiverIntegrations(tc.receiver, nil, nil) - if tc.err { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Len(t, integrations, len(tc.exp)) - for i := range tc.exp { - require.Equal(t, tc.exp[i].SendResolved(), integrations[i].SendResolved()) - require.Equal(t, tc.exp[i].Name(), integrations[i].Name()) - require.Equal(t, tc.exp[i].Index(), integrations[i].Index()) - } - }) + t.Run( + "", func(t *testing.T) { + integrations, err := BuildReceiverIntegrations(tc.receiver, nil, nil) + if tc.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, integrations, len(tc.exp)) + for i := range tc.exp { + require.Equal(t, tc.exp[i].SendResolved(), integrations[i].SendResolved()) + require.Equal(t, tc.exp[i].Name(), integrations[i].Name()) + require.Equal(t, tc.exp[i].Index(), integrations[i].Index()) + } + }, + ) } } diff --git a/notify/webhook_template/webhook_template.go b/notify/webhook_template/webhook_template.go new file mode 100644 index 0000000000..21e1035d86 --- /dev/null +++ b/notify/webhook_template/webhook_template.go @@ -0,0 +1,112 @@ +// 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. + +package webhook_template + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + commoncfg "github.com/prometheus/common/config" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +// Notifier implements a Notifier for generic webhooks. +type Notifier struct { + conf *config.WebhookTemplateConfig + tmpl *template.Template + logger *slog.Logger + client *http.Client + retrier *notify.Retrier +} + +// New returns a new Webhook. +func New(conf *config.WebhookTemplateConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { + client, err := commoncfg.NewClientFromConfig(*conf.HTTPConfig, "webhook_template", httpOpts...) + if err != nil { + return nil, err + } + return &Notifier{ + conf: conf, + tmpl: t, + logger: l, + client: client, + // Webhooks are assumed to respond with 2xx response codes on a successful + // request and 5xx response codes are assumed to be recoverable. + retrier: ¬ify.Retrier{}, + }, nil +} + +// Notify implements the Notifier interface. +func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + n.logger.Debug("extracted group key", "key", key) + + data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger) + tmpl := notify.TmplText(n.tmpl, data, &err) + if err != nil { + return false, err + } + + body := tmpl(n.conf.Template) + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return false, err + } + + var url string + if n.conf.URL != nil { + url = n.conf.URL.String() + } else { + content, err := os.ReadFile(n.conf.URLFile) + if err != nil { + return false, fmt.Errorf("read url_file: %w", err) + } + url = strings.TrimSpace(string(content)) + } + + if n.conf.Timeout > 0 { + postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured webhook template timeout reached (%s)", n.conf.Timeout)) + defer cancel() + ctx = postCtx + } + + resp, err := notify.PostJSON(ctx, n.client, url, &buf) + if err != nil { + if ctx.Err() != nil { + err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) + } + return true, notify.RedactURL(err) + } + defer notify.Drain(resp) + + shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) + if err != nil { + return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) + } + return shouldRetry, err +} diff --git a/notify/webhook_template/webhook_template_test.go b/notify/webhook_template/webhook_template_test.go new file mode 100644 index 0000000000..f6b7c2da78 --- /dev/null +++ b/notify/webhook_template/webhook_template_test.go @@ -0,0 +1,132 @@ +// 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. + +package webhook_template + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "os" + "testing" + + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify/test" +) + +func TestWebhookRetry(t *testing.T) { + u, err := url.Parse("http://example.com") + if err != nil { + require.NoError(t, err) + } + notifier, err := New( + &config.WebhookTemplateConfig{ + URL: &config.SecretURL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + test.CreateTmpl(t), + promslog.NewNopLogger(), + ) + if err != nil { + require.NoError(t, err) + } + + t.Run( + "test retry status code", func(t *testing.T) { + for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { + actual, _ := notifier.retrier.Check(statusCode, nil) + require.Equal(t, expected, actual, fmt.Sprintf("error on status %d", statusCode)) + } + }, + ) + + t.Run( + "test retry error details", func(t *testing.T) { + for _, tc := range []struct { + status int + body io.Reader + + exp string + }{ + { + status: http.StatusBadRequest, + body: bytes.NewBuffer( + []byte( + `{"status":"invalid event"}`, + ), + ), + + exp: fmt.Sprintf(`unexpected status code %d: {"status":"invalid event"}`, http.StatusBadRequest), + }, + { + status: http.StatusBadRequest, + + exp: fmt.Sprintf(`unexpected status code %d`, http.StatusBadRequest), + }, + } { + t.Run( + "", func(t *testing.T) { + _, err = notifier.retrier.Check(tc.status, tc.body) + require.Equal(t, tc.exp, err.Error()) + }, + ) + } + }, + ) +} + +func TestWebhookRedactedURL(t *testing.T) { + ctx, u, fn := test.GetContextWithCancelingURL() + defer fn() + + secret := "secret" + notifier, err := New( + &config.WebhookTemplateConfig{ + URL: &config.SecretURL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + test.CreateTmpl(t), + promslog.NewNopLogger(), + ) + require.NoError(t, err) + + test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) +} + +func TestWebhookReadingURLFromFile(t *testing.T) { + ctx, u, fn := test.GetContextWithCancelingURL() + defer fn() + + f, err := os.CreateTemp("", "webhook_url") + require.NoError(t, err, "creating temp file failed") + _, err = f.WriteString(u.String() + "\n") + require.NoError(t, err, "writing to temp file failed") + + notifier, err := New( + &config.WebhookTemplateConfig{ + URLFile: f.Name(), + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + test.CreateTmpl(t), + promslog.NewNopLogger(), + ) + require.NoError(t, err) + + test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) +}