diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6d311560..765f483145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## main / (unreleased) -* [CHANGE] ... +* [CHANGE] slack: Derive the `chat.update` URL from the configured `api_url` base instead of using the hardcoded `https://slack.com/api/chat.update`. Standard configurations are unaffected; deployments that point `api_url` at a Slack-compatible proxy/forwarder will now have their update requests routed to that same host. #5225 * [FEATURE] ... * [ENHANCEMENT] ... diff --git a/config/config.go b/config/config.go index 0d6899122c..0b96b2c586 100644 --- a/config/config.go +++ b/config/config.go @@ -36,6 +36,7 @@ import ( "github.com/prometheus/alertmanager/notify/incidentio" "github.com/prometheus/alertmanager/notify/jira" "github.com/prometheus/alertmanager/notify/mattermost" + "github.com/prometheus/alertmanager/notify/slack" "github.com/prometheus/alertmanager/notify/webhook" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/tracing" @@ -411,7 +412,7 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { } for _, sc := range rcv.SlackConfigs { if sc == nil { - sc = &SlackConfig{} + sc = &slack.SlackConfig{} } sc.AppURL = cmp.Or(sc.AppURL, c.Global.SlackAppURL) if sc.AppURL == nil { @@ -429,6 +430,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 { return errors.New("no Slack API URL nor App token set either inline or in a file") } + if err := sc.ValidateMessageStrategy(); err != nil { + return err + } if sc.HTTPConfig == nil { // we don't want to change the global http config when setting the receiver's http config, do we do a copy httpconfig := *c.Global.HTTPConfig @@ -947,7 +951,7 @@ type Receiver struct { EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"` IncidentioConfigs []*incidentio.IncidentioConfig `yaml:"incidentio_configs,omitempty" json:"incidentio_configs,omitempty"` PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"` - SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"` + SlackConfigs []*slack.SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"` WebhookConfigs []*webhook.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"` diff --git a/config/config_test.go b/config/config_test.go index 9f920dd39e..29de1df689 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1072,13 +1072,106 @@ func TestSlackBothAppTokenAndAPIURL(t *testing.T) { } } -func TestSlackUpdateMessageWebhookURL(t *testing.T) { - _, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml") +func TestSlackMessageStrategyWithWrongAPIURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + strategy string + }{ + { + name: "update strategy with webhook URL", + file: "testdata/conf.slack-update-message-and-webhook.yml", + strategy: `update`, + }, + { + name: "update strategy with webhook URL (new field)", + file: "testdata/conf.slack-update-message-and-webhook-with-new-field.yml", + strategy: `update`, + }, + { + name: "thread strategy with webhook URL", + file: "testdata/conf.slack-thread-message-and-webhook.yml", + strategy: `thread`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := LoadFile(tt.file) + if err == nil { + t.Fatal("Expected an error") + } + expectedErrMsg := fmt.Sprintf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", tt.strategy) + if err.Error() != expectedErrMsg { + t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error()) + } + }) + } +} + +func TestSlackMessageStrategyWithGlobalAPIURL(t *testing.T) { + t.Parallel() + + _, err := LoadFile("testdata/conf.slack-thread-strategy-with-global-api-url.yml") + if err != nil { + t.Fatalf("Expected no error when message_strategy uses global slack_api_url, got: %s", err) + } +} + +func TestSlackMessageStrategyWithoutAPIURL(t *testing.T) { + t.Parallel() + + _, err := LoadFile("testdata/conf.slack-thread-strategy-without-api-url.yml") if err == nil { - t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err) + t.Fatal("Expected an error when message_strategy: thread has no api_url at any level") + } + expectedErrMsg := `no Slack API URL nor App token set either inline or in a file` + if err.Error() != expectedErrMsg { + t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error()) + } +} + +func TestSlackThreadedOptionsValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + expectedErrMsg string + }{ + { + name: "threaded options without thread strategy (resolve emoji)", + file: "testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml", + expectedErrMsg: `threaded_options requires message_strategy to be "thread"`, + }, + { + name: "threaded options without thread strategy (update parent)", + file: "testdata/conf.slack-thread-update-parent-without-thread-message.yml", + expectedErrMsg: `threaded_options requires message_strategy to be "thread"`, + }, + { + name: "resolve color without summary header", + file: "testdata/conf.slack-resolve-color-without-summary-header.yml", + expectedErrMsg: `threaded_options.summary_header requires use_summary_header to be enabled`, + }, } - if err.Error() != "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" { - t.Errorf("Expected: %s\nGot: %s", "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := LoadFile(tt.file) + if err == nil { + t.Fatal("Expected an error") + } + if err.Error() != tt.expectedErrMsg { + t.Errorf("Expected: %s\nGot: %s", tt.expectedErrMsg, err.Error()) + } + }) } } diff --git a/config/notifiers.go b/config/notifiers.go index c58698fbee..7ab80a82c6 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -69,23 +69,6 @@ var ( ClientURL: `{{ template "pagerduty.default.clientURL" . }}`, } - // DefaultSlackConfig defines default values for Slack configurations. - DefaultSlackConfig = SlackConfig{ - NotifierConfig: amcommoncfg.NotifierConfig{ - VSendResolved: false, - }, - Color: `{{ template "slack.default.color" . }}`, - Username: `{{ template "slack.default.username" . }}`, - Title: `{{ template "slack.default.title" . }}`, - TitleLink: `{{ template "slack.default.titlelink" . }}`, - IconEmoji: `{{ template "slack.default.iconemoji" . }}`, - IconURL: `{{ template "slack.default.iconurl" . }}`, - Pretext: `{{ template "slack.default.pretext" . }}`, - Text: `{{ template "slack.default.text" . }}`, - Fallback: `{{ template "slack.default.fallback" . }}`, - CallbackID: `{{ template "slack.default.callbackid" . }}`, - Footer: `{{ template "slack.default.footer" . }}`, - } // DefaultRocketchatConfig defines default values for Rocketchat configurations. DefaultRocketchatConfig = RocketchatConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ @@ -352,160 +335,6 @@ func (c *PagerdutyConfig) UnmarshalYAML(unmarshal func(any) error) error { return nil } -// SlackAction configures a single Slack action that is sent with each notification. -// See https://api.slack.com/docs/message-attachments#action_fields and https://api.slack.com/docs/message-buttons -// for more information. -type SlackAction struct { - Type string `yaml:"type,omitempty" json:"type,omitempty"` - Text string `yaml:"text,omitempty" json:"text,omitempty"` - URL string `yaml:"url,omitempty" json:"url,omitempty"` - Style string `yaml:"style,omitempty" json:"style,omitempty"` - Name string `yaml:"name,omitempty" json:"name,omitempty"` - Value string `yaml:"value,omitempty" json:"value,omitempty"` - ConfirmField *SlackConfirmationField `yaml:"confirm,omitempty" json:"confirm,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackAction. -func (c *SlackAction) UnmarshalYAML(unmarshal func(any) error) error { - type plain SlackAction - if err := unmarshal((*plain)(c)); err != nil { - return err - } - if c.Type == "" { - return errors.New("missing type in Slack action configuration") - } - if c.Text == "" { - return errors.New("missing text in Slack action configuration") - } - if c.URL != "" { - // Clear all message action fields. - c.Name = "" - c.Value = "" - c.ConfirmField = nil - } else if c.Name != "" { - c.URL = "" - } else { - return errors.New("missing name or url in Slack action configuration") - } - return nil -} - -// SlackConfirmationField protect users from destructive actions or particularly distinguished decisions -// by asking them to confirm their button click one more time. -// See https://api.slack.com/docs/interactive-message-field-guide#confirmation_fields for more information. -type SlackConfirmationField struct { - Text string `yaml:"text,omitempty" json:"text,omitempty"` - Title string `yaml:"title,omitempty" json:"title,omitempty"` - OkText string `yaml:"ok_text,omitempty" json:"ok_text,omitempty"` - DismissText string `yaml:"dismiss_text,omitempty" json:"dismiss_text,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackConfirmationField. -func (c *SlackConfirmationField) UnmarshalYAML(unmarshal func(any) error) error { - type plain SlackConfirmationField - if err := unmarshal((*plain)(c)); err != nil { - return err - } - if c.Text == "" { - return errors.New("missing text in Slack confirmation configuration") - } - return nil -} - -// SlackField configures a single Slack field that is sent with each notification. -// Each field must contain a title, value, and optionally, a boolean value to indicate if the field -// is short enough to be displayed next to other fields designated as short. -// See https://api.slack.com/docs/message-attachments#fields for more information. -type SlackField struct { - Title string `yaml:"title,omitempty" json:"title,omitempty"` - Value string `yaml:"value,omitempty" json:"value,omitempty"` - Short *bool `yaml:"short,omitempty" json:"short,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackField. -func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error { - type plain SlackField - if err := unmarshal((*plain)(c)); err != nil { - return err - } - if c.Title == "" { - return errors.New("missing title in Slack field configuration") - } - if c.Value == "" { - return errors.New("missing value in Slack field configuration") - } - return nil -} - -// SlackConfig configures notifications via Slack. -type SlackConfig struct { - amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` - - HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` - - APIURL *amcommoncfg.SecretURL `yaml:"api_url,omitempty" json:"api_url,omitempty"` - APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"` - AppToken commoncfg.Secret `yaml:"app_token,omitempty" json:"app_token,omitempty"` - AppTokenFile string `yaml:"app_token_file,omitempty" json:"app_token_file,omitempty"` - AppURL *amcommoncfg.URL `yaml:"app_url,omitempty" json:"app_url,omitempty"` - - // Slack channel override, (like #other-channel or @username). - Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Color string `yaml:"color,omitempty" json:"color,omitempty"` - - Title string `yaml:"title,omitempty" json:"title,omitempty"` - TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` - Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` - Text string `yaml:"text,omitempty" json:"text,omitempty"` - MessageText string `yaml:"message_text,omitempty" json:"message_text,omitempty"` - Fields []*SlackField `yaml:"fields,omitempty" json:"fields,omitempty"` - ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` - Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` - Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` - CallbackID string `yaml:"callback_id,omitempty" json:"callback_id,omitempty"` - IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` - IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` - ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` - ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` - LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` - MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"` - Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"` - - // UpdateMessage enables updating existing Slack messages instead of creating new ones. - // Requires bot token with chat:write scope. Webhook URLs do not support updates. - - UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"` - // Timeout is the maximum time allowed to invoke the slack. Setting this to 0 - // does not impose a timeout. - Timeout time.Duration `yaml:"timeout" json:"timeout"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { - *c = DefaultSlackConfig - type plain SlackConfig - if err := unmarshal((*plain)(c)); err != nil { - return err - } - - if c.APIURL != nil && len(c.APIURLFile) > 0 { - return errors.New("at most one of api_url & api_url_file must be configured") - } - if c.AppToken != "" && len(c.AppTokenFile) > 0 { - return errors.New("at most one of app_token & app_token_file must be configured") - } - if (c.APIURL != nil || len(c.APIURLFile) > 0) && (c.AppToken != "" || len(c.AppTokenFile) > 0) { - return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured") - } - - if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" { - return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage") - } - - return nil -} - // WechatConfig configures notifications via Wechat. type WechatConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` diff --git a/config/notifiers_test.go b/config/notifiers_test.go index 9b75b2ec99..31a303ea07 100644 --- a/config/notifiers_test.go +++ b/config/notifiers_test.go @@ -448,319 +448,6 @@ monospace: true } } -func TestLoadSlackConfiguration(t *testing.T) { - tests := []struct { - in string - expected SlackConfig - }{ - { - in: ` -color: green -username: mark -channel: engineering -title_link: http://example.com/ -image_url: https://example.com/logo.png -`, - expected: SlackConfig{ - Color: "green", Username: "mark", Channel: "engineering", - TitleLink: "http://example.com/", - ImageURL: "https://example.com/logo.png", - }, - }, - { - in: ` -color: green -username: mark -channel: alerts -title_link: http://example.com/alert1 -mrkdwn_in: -- pretext -- text -`, - expected: SlackConfig{ - Color: "green", Username: "mark", Channel: "alerts", - MrkdwnIn: []string{"pretext", "text"}, TitleLink: "http://example.com/alert1", - }, - }, - } - for _, rt := range tests { - var cfg SlackConfig - err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) - if err != nil { - t.Fatalf("\nerror returned when none expected, error:\n%v", err) - } - if rt.expected.Color != cfg.Color { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Color, cfg.Color) - } - if rt.expected.Username != cfg.Username { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Username, cfg.Username) - } - if rt.expected.Channel != cfg.Channel { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Channel, cfg.Channel) - } - if rt.expected.ThumbURL != cfg.ThumbURL { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ThumbURL, cfg.ThumbURL) - } - if rt.expected.TitleLink != cfg.TitleLink { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.TitleLink, cfg.TitleLink) - } - if rt.expected.ImageURL != cfg.ImageURL { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ImageURL, cfg.ImageURL) - } - if len(rt.expected.MrkdwnIn) != len(cfg.MrkdwnIn) { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.MrkdwnIn, cfg.MrkdwnIn) - } - for i := range cfg.MrkdwnIn { - if rt.expected.MrkdwnIn[i] != cfg.MrkdwnIn[i] { - t.Errorf("\nexpected:\n%v\ngot:\n%v\nat index %v", rt.expected.MrkdwnIn[i], cfg.MrkdwnIn[i], i) - } - } - } -} - -func TestSlackAuthMethodConfigValidation(t *testing.T) { - tests := []struct { - in string - expectedErr string - }{ - { - in: ` -api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' -api_url_file: /slack_url -`, - expectedErr: "at most one of api_url & api_url_file must be configured", - }, - { - in: ` -app_token: 'xoxb-1234-abcdefgh' -app_token_file: /slack_app_token -`, - expectedErr: "at most one of app_token & app_token_file must be configured", - }, - { - in: ` -app_token: 'xoxb-1234-abcdefgh' -api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' -`, - expectedErr: "at most one of api_url/api_url_file & app_token/app_token_file must be configured", - }, - } - - for _, rt := range tests { - var cfg SlackConfig - err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) - - // Check if an error occurred when it was NOT expected to. - if rt.expectedErr == "" && err != nil { - t.Fatalf("\nerror returned when none expected, error:\n%v", err) - } - // Check that an error occurred if one was expected to. - if rt.expectedErr != "" && err == nil { - t.Fatalf("\nno error returned, expected:\n%v", rt.expectedErr) - } - // Check that the error that occurred was what was expected. - if err != nil && err.Error() != rt.expectedErr { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expectedErr, err.Error()) - } - } -} - -func TestSlackFieldConfigValidation(t *testing.T) { - tests := []struct { - in string - expected string - }{ - { - in: ` -fields: -- title: first - value: hello -- title: second -`, - expected: "missing value in Slack field configuration", - }, - { - in: ` -fields: -- title: first - value: hello - short: true -- value: world - short: true -`, - expected: "missing title in Slack field configuration", - }, - { - in: ` -fields: -- title: first - value: hello - short: true -- title: second - value: world -`, - expected: "", - }, - } - - for _, rt := range tests { - var cfg SlackConfig - err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) - - // Check if an error occurred when it was NOT expected to. - if rt.expected == "" && err != nil { - t.Fatalf("\nerror returned when none expected, error:\n%v", err) - } - // Check that an error occurred if one was expected to. - if rt.expected != "" && err == nil { - t.Fatalf("\nno error returned, expected:\n%v", rt.expected) - } - // Check that the error that occurred was what was expected. - if err != nil && err.Error() != rt.expected { - t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected, err.Error()) - } - } -} - -func TestSlackFieldConfigUnmarshaling(t *testing.T) { - in := ` -fields: -- title: first - value: hello - short: true -- title: second - value: world -- title: third - value: slack field test - short: false -` - expected := []*SlackField{ - { - Title: "first", - Value: "hello", - Short: newBoolPointer(true), - }, - { - Title: "second", - Value: "world", - Short: nil, - }, - { - Title: "third", - Value: "slack field test", - Short: newBoolPointer(false), - }, - } - - var cfg SlackConfig - err := yaml.UnmarshalStrict([]byte(in), &cfg) - if err != nil { - t.Fatalf("\nerror returned when none expected, error:\n%v", err) - } - - for index, field := range cfg.Fields { - exp := expected[index] - if field.Title != exp.Title { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Title, field.Title) - } - if field.Value != exp.Value { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, field.Value) - } - if exp.Short == nil && field.Short != nil { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Short, *field.Short) - } - if exp.Short != nil && field.Short == nil { - t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, field.Short) - } - if exp.Short != nil && *exp.Short != *field.Short { - t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, *field.Short) - } - } -} - -func TestSlackActionsValidation(t *testing.T) { - in := ` -actions: -- type: button - text: hello - url: https://localhost - style: danger -- type: button - text: hello - name: something - style: default - confirm: - title: please confirm - text: are you sure? - ok_text: yes - dismiss_text: no -` - expected := []*SlackAction{ - { - Type: "button", - Text: "hello", - URL: "https://localhost", - Style: "danger", - }, - { - Type: "button", - Text: "hello", - Name: "something", - Style: "default", - ConfirmField: &SlackConfirmationField{ - Title: "please confirm", - Text: "are you sure?", - OkText: "yes", - DismissText: "no", - }, - }, - } - - var cfg SlackConfig - err := yaml.UnmarshalStrict([]byte(in), &cfg) - if err != nil { - t.Fatalf("\nerror returned when none expected, error:\n%v", err) - } - - for index, action := range cfg.Actions { - exp := expected[index] - if action.Type != exp.Type { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Type, action.Type) - } - if action.Text != exp.Text { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Text, action.Text) - } - if action.URL != exp.URL { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.URL, action.URL) - } - if action.Style != exp.Style { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Style, action.Style) - } - if action.Name != exp.Name { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Name, action.Name) - } - if action.Value != exp.Value { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, action.Value) - } - if action.ConfirmField != nil && exp.ConfirmField == nil || action.ConfirmField == nil && exp.ConfirmField != nil { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField, action.ConfirmField) - } else if action.ConfirmField != nil && exp.ConfirmField != nil { - if action.ConfirmField.Title != exp.ConfirmField.Title { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Title, action.ConfirmField.Title) - } - if action.ConfirmField.Text != exp.ConfirmField.Text { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Text, action.ConfirmField.Text) - } - if action.ConfirmField.OkText != exp.ConfirmField.OkText { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.OkText, action.ConfirmField.OkText) - } - if action.ConfirmField.DismissText != exp.ConfirmField.DismissText { - t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.DismissText, action.ConfirmField.DismissText) - } - } - } -} - func TestOpsgenieTypeMatcher(t *testing.T) { good := []string{"team", "user", "escalation", "schedule"} for _, g := range good { @@ -1077,10 +764,6 @@ parse_mode: invalid } } -func newBoolPointer(b bool) *bool { - return &b -} - func TestEmailConfig_UnmarshalYAML(t *testing.T) { testConfig := []struct { name string diff --git a/config/testdata/conf.slack-resolve-color-without-summary-header.yml b/config/testdata/conf.slack-resolve-color-without-summary-header.yml new file mode 100644 index 0000000000..609b432d87 --- /dev/null +++ b/config/testdata/conf.slack-resolve-color-without-summary-header.yml @@ -0,0 +1,18 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts1' + text: 'test' + send_resolved: true + api_url: 'https://slack.com/api/chat.postMessage' + http_config: + authorization: + credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX' + message_strategy: thread + threaded_options: + use_summary_header: false + summary_header: + resolve_color: '#AAAAAA' diff --git a/config/testdata/conf.slack-thread-message-and-webhook.yml b/config/testdata/conf.slack-thread-message-and-webhook.yml new file mode 100644 index 0000000000..a326d54e98 --- /dev/null +++ b/config/testdata/conf.slack-thread-message-and-webhook.yml @@ -0,0 +1,11 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts1' + text: 'test' + send_resolved: true + api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + message_strategy: thread diff --git a/config/testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml b/config/testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml new file mode 100644 index 0000000000..2689fa952a --- /dev/null +++ b/config/testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml @@ -0,0 +1,15 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts1' + text: 'test' + send_resolved: true + api_url: 'https://slack.com/api/chat.postMessage' + http_config: + authorization: + credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX' + threaded_options: + resolve_emoji: 'white_check_mark' diff --git a/config/testdata/conf.slack-thread-strategy-with-global-api-url.yml b/config/testdata/conf.slack-thread-strategy-with-global-api-url.yml new file mode 100644 index 0000000000..5927949ff9 --- /dev/null +++ b/config/testdata/conf.slack-thread-strategy-with-global-api-url.yml @@ -0,0 +1,14 @@ +global: + slack_api_url: 'https://slack.com/api/chat.postMessage' +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts' + text: 'test' + http_config: + authorization: + credentials: 'xoxb-test-token' + message_strategy: thread diff --git a/config/testdata/conf.slack-thread-strategy-without-api-url.yml b/config/testdata/conf.slack-thread-strategy-without-api-url.yml new file mode 100644 index 0000000000..c4e27376be --- /dev/null +++ b/config/testdata/conf.slack-thread-strategy-without-api-url.yml @@ -0,0 +1,9 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts' + text: 'test' + message_strategy: thread diff --git a/config/testdata/conf.slack-thread-update-parent-without-thread-message.yml b/config/testdata/conf.slack-thread-update-parent-without-thread-message.yml new file mode 100644 index 0000000000..2689fa952a --- /dev/null +++ b/config/testdata/conf.slack-thread-update-parent-without-thread-message.yml @@ -0,0 +1,15 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + - channel: '#alerts1' + text: 'test' + send_resolved: true + api_url: 'https://slack.com/api/chat.postMessage' + http_config: + authorization: + credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX' + threaded_options: + resolve_emoji: 'white_check_mark' diff --git a/config/testdata/conf.slack-update-message-and-webhook-with-new-field.yml b/config/testdata/conf.slack-update-message-and-webhook-with-new-field.yml new file mode 100644 index 0000000000..4f4d2071ab --- /dev/null +++ b/config/testdata/conf.slack-update-message-and-webhook-with-new-field.yml @@ -0,0 +1,13 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + # use global + - channel: '#alerts1' + text: 'test' + send_resolved: true + # trying to use webhook urls with update_message + api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + message_strategy: update diff --git a/docs/configuration.md b/docs/configuration.md index 72cce8c2df..6a0d124716 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1664,9 +1664,43 @@ fields: # NOTE: This will have no effect if set higher than the group_interval. [ timeout: | default = 0s ] -# Enables updating existing Slack messages instead of creating new ones on alert state change. -# Webhook URLs do not support updates. -[ update_message: | default = false ] +# Controls how subsequent notifications for the same alert group are delivered. +# "new" (default): each notification is a separate message. +# "update": subsequent notifications update the original message in-place. +# "thread": subsequent notifications are sent as threaded replies. +# "update" and "thread" require api_url or api_url_file resolving to +# https://slack.com/api/chat.postMessage (bot token). +[ message_strategy: | default = "new" ] + +# Limitation for "thread" (and "update"): thread metadata (for example the parent +# message timestamp and channel id) is written to the notification log only after +# the integration returns success for the entire notification attempt. If Slack +# accepts an earlier API call in the same attempt but a later call fails and retries +# are exhausted, the next flush may not see stored thread state and can open a new +# thread in the channel, leaving the previous thread without further updates. + +# Options for threaded message behavior. Only valid when message_strategy is "thread". +threaded_options: + # When true (default), the thread parent is a lightweight auto-updated summary + # header and all alert content is posted as replies. When false, the first + # alert message IS the thread parent and subsequent alerts are threaded replies. + [ use_summary_header: | default = true ] + + # Emoji name (without colons) to react with on the parent thread message + # when all alerts in the group are resolved (e.g. "white_check_mark"). + # Requires the bot token to have reactions:write scope. + [ resolve_emoji: ] + + # Options that only apply when use_summary_header is true (default). + [ summary_header: ] +``` + +#### `` + +```yaml +# Overrides the parent summary attachment color when all alerts resolve. +# Supports Go templates. +[ resolve_color: ] ``` #### `` (Slack) diff --git a/notify/slack/config.go b/notify/slack/config.go new file mode 100644 index 0000000000..7bf340f855 --- /dev/null +++ b/notify/slack/config.go @@ -0,0 +1,308 @@ +// Copyright The Prometheus Authors +// 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 slack + +import ( + "errors" + "fmt" + "time" + + commoncfg "github.com/prometheus/common/config" + + amcommoncfg "github.com/prometheus/alertmanager/config/common" +) + +const ( + // SlackMessageStrategyNew sends each notification as a separate message (default). + SlackMessageStrategyNew SlackMessageStrategy = "new" + // SlackMessageStrategyUpdate updates the original message in-place. + SlackMessageStrategyUpdate SlackMessageStrategy = "update" + // SlackMessageStrategyThread sends subsequent notifications as threaded replies. + SlackMessageStrategyThread SlackMessageStrategy = "thread" +) + +// DefaultSlackConfig defines default values for Slack configurations. +var DefaultSlackConfig = SlackConfig{ + NotifierConfig: amcommoncfg.NotifierConfig{ + VSendResolved: false, + }, + Color: `{{ template "slack.default.color" . }}`, + Username: `{{ template "slack.default.username" . }}`, + Title: `{{ template "slack.default.title" . }}`, + TitleLink: `{{ template "slack.default.titlelink" . }}`, + IconEmoji: `{{ template "slack.default.iconemoji" . }}`, + IconURL: `{{ template "slack.default.iconurl" . }}`, + Pretext: `{{ template "slack.default.pretext" . }}`, + Text: `{{ template "slack.default.text" . }}`, + Fallback: `{{ template "slack.default.fallback" . }}`, + CallbackID: `{{ template "slack.default.callbackid" . }}`, + Footer: `{{ template "slack.default.footer" . }}`, +} + +// SlackAction configures a single Slack action that is sent with each notification. +// See https://api.slack.com/docs/message-attachments#action_fields and https://api.slack.com/docs/message-buttons +// for more information. +type SlackAction struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Text string `yaml:"text,omitempty" json:"text,omitempty"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` + Style string `yaml:"style,omitempty" json:"style,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Value string `yaml:"value,omitempty" json:"value,omitempty"` + ConfirmField *SlackConfirmationField `yaml:"confirm,omitempty" json:"confirm,omitempty"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackAction. +func (c *SlackAction) UnmarshalYAML(unmarshal func(any) error) error { + type plain SlackAction + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.Type == "" { + return errors.New("missing type in Slack action configuration") + } + if c.Text == "" { + return errors.New("missing text in Slack action configuration") + } + if c.URL != "" { + // Clear all message action fields. + c.Name = "" + c.Value = "" + c.ConfirmField = nil + } else if c.Name != "" { + c.URL = "" + } else { + return errors.New("missing name or url in Slack action configuration") + } + return nil +} + +// SlackConfirmationField protect users from destructive actions or particularly distinguished decisions +// by asking them to confirm their button click one more time. +// See https://api.slack.com/docs/interactive-message-field-guide#confirmation_fields for more information. +type SlackConfirmationField struct { + Text string `yaml:"text,omitempty" json:"text,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + OkText string `yaml:"ok_text,omitempty" json:"ok_text,omitempty"` + DismissText string `yaml:"dismiss_text,omitempty" json:"dismiss_text,omitempty"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackConfirmationField. +func (c *SlackConfirmationField) UnmarshalYAML(unmarshal func(any) error) error { + type plain SlackConfirmationField + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.Text == "" { + return errors.New("missing text in Slack confirmation configuration") + } + return nil +} + +// SlackField configures a single Slack field that is sent with each notification. +// Each field must contain a title, value, and optionally, a boolean value to indicate if the field +// is short enough to be displayed next to other fields designated as short. +// See https://api.slack.com/docs/message-attachments#fields for more information. +type SlackField struct { + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Value string `yaml:"value,omitempty" json:"value,omitempty"` + Short *bool `yaml:"short,omitempty" json:"short,omitempty"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for SlackField. +func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error { + type plain SlackField + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.Title == "" { + return errors.New("missing title in Slack field configuration") + } + if c.Value == "" { + return errors.New("missing value in Slack field configuration") + } + return nil +} + +// SlackMessageStrategy controls how subsequent notifications for the same +// alert group are delivered to Slack. +type SlackMessageStrategy string + +// SlackThreadedOptions configures thread-specific behavior when message_strategy is "thread". +type SlackThreadedOptions struct { + // ResolveEmoji is the emoji name (without colons) to react with on the + // original thread message when all alerts in the group are resolved. + // Requires the bot token to have reactions:write scope. + ResolveEmoji string `yaml:"resolve_emoji,omitempty" json:"resolve_emoji,omitempty"` + + // UseSummaryHeader controls whether the thread parent is a lightweight + // auto-updated summary (true, default) or the first actual alert message + // (false). When true, all alert content is posted as replies and the parent + // is continuously updated with the transition title and color. + UseSummaryHeader *bool `yaml:"use_summary_header,omitempty" json:"use_summary_header,omitempty"` + + // SummaryHeader holds options for summary-header mode only (see UseSummaryHeader). + SummaryHeader *SlackThreadSummaryHeaderOptions `yaml:"summary_header,omitempty" json:"summary_header,omitempty"` +} + +// SlackThreadSummaryHeaderOptions configures fields that only apply when +// message_strategy is "thread" and use_summary_header is true (the lightweight +// parent summary mode). +type SlackThreadSummaryHeaderOptions struct { + // ResolveColor overrides the parent summary attachment color when the alert + // group resolves. Supports Go templates. + ResolveColor string `yaml:"resolve_color,omitempty" json:"resolve_color,omitempty"` +} + +// SlackConfig configures notifications via Slack. +type SlackConfig struct { + amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + APIURL *amcommoncfg.SecretURL `yaml:"api_url,omitempty" json:"api_url,omitempty"` + APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"` + AppToken commoncfg.Secret `yaml:"app_token,omitempty" json:"app_token,omitempty"` + AppTokenFile string `yaml:"app_token_file,omitempty" json:"app_token_file,omitempty"` + AppURL *amcommoncfg.URL `yaml:"app_url,omitempty" json:"app_url,omitempty"` + + // Slack channel override, (like #other-channel or @username). + Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Color string `yaml:"color,omitempty" json:"color,omitempty"` + + Title string `yaml:"title,omitempty" json:"title,omitempty"` + TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` + Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` + Text string `yaml:"text,omitempty" json:"text,omitempty"` + MessageText string `yaml:"message_text,omitempty" json:"message_text,omitempty"` + Fields []*SlackField `yaml:"fields,omitempty" json:"fields,omitempty"` + ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` + Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` + Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` + CallbackID string `yaml:"callback_id,omitempty" json:"callback_id,omitempty"` + IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` + IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` + ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` + ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` + LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` + MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"` + Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"` + + // MessageStrategy controls how subsequent notifications for the same alert + // group are delivered: "new" (default), "update" (edit in-place), or "thread" + // (threaded replies). "update" and "thread" require a bot token. + MessageStrategy SlackMessageStrategy `yaml:"message_strategy,omitempty" json:"message_strategy,omitempty"` + + // UpdateMessage enables updating existing Slack messages instead of creating new ones. + // Deprecated: use message_strategy: update instead. If true, message_strategy must + // be unset or "update"; when message_strategy is unset it is treated as "update". + UpdateMessage bool `yaml:"update_message,omitempty" json:"update_message,omitempty"` + + // ThreadedOptions configures thread-specific behavior. + // Only valid when message_strategy is "thread". + ThreadedOptions *SlackThreadedOptions `yaml:"threaded_options,omitempty" json:"threaded_options,omitempty"` + // Timeout is the maximum time allowed to invoke the slack. Setting this to 0 + // does not impose a timeout. + Timeout time.Duration `yaml:"timeout" json:"timeout"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultSlackConfig + type plain SlackConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.APIURL != nil && len(c.APIURLFile) > 0 { + return errors.New("at most one of api_url & api_url_file must be configured") + } + if c.AppToken != "" && len(c.AppTokenFile) > 0 { + return errors.New("at most one of app_token & app_token_file must be configured") + } + if (c.APIURL != nil || len(c.APIURLFile) > 0) && (c.AppToken != "" || len(c.AppTokenFile) > 0) { + return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured") + } + + // Deprecated: remove this block when update_message is deleted. + if c.UpdateMessage { + if c.MessageStrategy != "" && c.MessageStrategy != SlackMessageStrategyUpdate { + return fmt.Errorf("update_message: true is incompatible with message_strategy %q; omit message_strategy or set message_strategy: \"update\"", c.MessageStrategy) + } + if c.MessageStrategy == "" { + c.MessageStrategy = SlackMessageStrategyUpdate + } + } + if c.MessageStrategy == "" { + c.MessageStrategy = SlackMessageStrategyNew + } + + if c.MessageStrategy != SlackMessageStrategyNew && + c.MessageStrategy != SlackMessageStrategyUpdate && + c.MessageStrategy != SlackMessageStrategyThread { + return fmt.Errorf("unknown message_strategy %q; must be \"new\", \"update\", or \"thread\"", c.MessageStrategy) + } + + if c.ThreadedOptions != nil { + if c.MessageStrategy != SlackMessageStrategyThread { + return errors.New("threaded_options requires message_strategy to be \"thread\"") + } + if c.ThreadedOptions.UseSummaryHeader != nil && !*c.ThreadedOptions.UseSummaryHeader && c.ThreadedOptions.SummaryHeader != nil { + return errors.New("threaded_options.summary_header requires use_summary_header to be enabled") + } + } + + return nil +} + +// ValidateMessageStrategy checks that the resolved api_url (after global defaults +// have been applied) satisfies the requirements for update/thread strategies. +func (c *SlackConfig) ValidateMessageStrategy() error { + switch c.MessageStrategy { + case SlackMessageStrategyUpdate, SlackMessageStrategyThread: + if c.APIURL == nil && c.APIURLFile == "" { + return fmt.Errorf("message_strategy %q requires api_url or api_url_file", c.MessageStrategy) + } + if c.APIURL != nil && c.APIURL.String() != "https://slack.com/api/chat.postMessage" { + return fmt.Errorf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", c.MessageStrategy) + } + } + return nil +} + +// UseSummaryHeaderInThread returns true when the thread parent should be a lightweight +// auto-updated summary. Returns true by default (nil or explicit true). +func (c *SlackConfig) UseSummaryHeaderInThread() bool { + if c.ThreadedOptions == nil || c.ThreadedOptions.UseSummaryHeader == nil { + return true + } + return *c.ThreadedOptions.UseSummaryHeader +} + +// HasStrategyThatUpdatesParent reports whether message_strategy uses nflog to tie +// multiple notifications to one Slack message or thread. +func (c *SlackConfig) HasStrategyThatUpdatesParent() bool { + return c.HasUpdateStrategy() || c.HasThreadStrategy() +} + +// HasUpdateStrategy is true when message_strategy is "update" (chat.update in place). +func (c *SlackConfig) HasUpdateStrategy() bool { + return c.MessageStrategy == SlackMessageStrategyUpdate +} + +// HasThreadStrategy is true when message_strategy is "thread" (threaded replies). +func (c *SlackConfig) HasThreadStrategy() bool { + return c.MessageStrategy == SlackMessageStrategyThread +} diff --git a/notify/slack/config_test.go b/notify/slack/config_test.go new file mode 100644 index 0000000000..61453a50b1 --- /dev/null +++ b/notify/slack/config_test.go @@ -0,0 +1,337 @@ +// Copyright The Prometheus Authors +// 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 slack + +import ( + "testing" + + "gopkg.in/yaml.v2" +) + +func TestLoadSlackConfiguration(t *testing.T) { + tests := []struct { + in string + expected SlackConfig + }{ + { + in: ` +color: green +username: mark +channel: engineering +title_link: http://example.com/ +image_url: https://example.com/logo.png +`, + expected: SlackConfig{ + Color: "green", Username: "mark", Channel: "engineering", + TitleLink: "http://example.com/", + ImageURL: "https://example.com/logo.png", + }, + }, + { + in: ` +color: green +username: mark +channel: alerts +title_link: http://example.com/alert1 +mrkdwn_in: +- pretext +- text +`, + expected: SlackConfig{ + Color: "green", Username: "mark", Channel: "alerts", + MrkdwnIn: []string{"pretext", "text"}, TitleLink: "http://example.com/alert1", + }, + }, + } + for _, rt := range tests { + var cfg SlackConfig + err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) + if err != nil { + t.Fatalf("\nerror returned when none expected, error:\n%v", err) + } + if rt.expected.Color != cfg.Color { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Color, cfg.Color) + } + if rt.expected.Username != cfg.Username { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Username, cfg.Username) + } + if rt.expected.Channel != cfg.Channel { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Channel, cfg.Channel) + } + if rt.expected.ThumbURL != cfg.ThumbURL { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ThumbURL, cfg.ThumbURL) + } + if rt.expected.TitleLink != cfg.TitleLink { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.TitleLink, cfg.TitleLink) + } + if rt.expected.ImageURL != cfg.ImageURL { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ImageURL, cfg.ImageURL) + } + if len(rt.expected.MrkdwnIn) != len(cfg.MrkdwnIn) { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.MrkdwnIn, cfg.MrkdwnIn) + } + for i := range cfg.MrkdwnIn { + if rt.expected.MrkdwnIn[i] != cfg.MrkdwnIn[i] { + t.Errorf("\nexpected:\n%v\ngot:\n%v\nat index %v", rt.expected.MrkdwnIn[i], cfg.MrkdwnIn[i], i) + } + } + } +} + +func TestSlackAuthMethodConfigValidation(t *testing.T) { + tests := []struct { + in string + expectedErr string + }{ + { + in: ` +api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' +api_url_file: /slack_url +`, + expectedErr: "at most one of api_url & api_url_file must be configured", + }, + { + in: ` +app_token: 'xoxb-1234-abcdefgh' +app_token_file: /slack_app_token +`, + expectedErr: "at most one of app_token & app_token_file must be configured", + }, + { + in: ` +app_token: 'xoxb-1234-abcdefgh' +api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' +`, + expectedErr: "at most one of api_url/api_url_file & app_token/app_token_file must be configured", + }, + } + + for _, rt := range tests { + var cfg SlackConfig + err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) + + // Check if an error occurred when it was NOT expected to. + if rt.expectedErr == "" && err != nil { + t.Fatalf("\nerror returned when none expected, error:\n%v", err) + } + // Check that an error occurred if one was expected to. + if rt.expectedErr != "" && err == nil { + t.Fatalf("\nno error returned, expected:\n%v", rt.expectedErr) + } + // Check that the error that occurred was what was expected. + if err != nil && err.Error() != rt.expectedErr { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expectedErr, err.Error()) + } + } +} + +func TestSlackFieldConfigValidation(t *testing.T) { + tests := []struct { + in string + expected string + }{ + { + in: ` +fields: +- title: first + value: hello +- title: second +`, + expected: "missing value in Slack field configuration", + }, + { + in: ` +fields: +- title: first + value: hello + short: true +- value: world + short: true +`, + expected: "missing title in Slack field configuration", + }, + { + in: ` +fields: +- title: first + value: hello + short: true +- title: second + value: world +`, + expected: "", + }, + } + + for _, rt := range tests { + var cfg SlackConfig + err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) + + // Check if an error occurred when it was NOT expected to. + if rt.expected == "" && err != nil { + t.Fatalf("\nerror returned when none expected, error:\n%v", err) + } + // Check that an error occurred if one was expected to. + if rt.expected != "" && err == nil { + t.Fatalf("\nno error returned, expected:\n%v", rt.expected) + } + // Check that the error that occurred was what was expected. + if err != nil && err.Error() != rt.expected { + t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected, err.Error()) + } + } +} + +func TestSlackFieldConfigUnmarshaling(t *testing.T) { + in := ` +fields: +- title: first + value: hello + short: true +- title: second + value: world +- title: third + value: slack field test + short: false +` + expected := []*SlackField{ + { + Title: "first", + Value: "hello", + Short: newBoolPointer(true), + }, + { + Title: "second", + Value: "world", + Short: nil, + }, + { + Title: "third", + Value: "slack field test", + Short: newBoolPointer(false), + }, + } + + var cfg SlackConfig + err := yaml.UnmarshalStrict([]byte(in), &cfg) + if err != nil { + t.Fatalf("\nerror returned when none expected, error:\n%v", err) + } + + for index, field := range cfg.Fields { + exp := expected[index] + if field.Title != exp.Title { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Title, field.Title) + } + if field.Value != exp.Value { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, field.Value) + } + if exp.Short == nil && field.Short != nil { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Short, *field.Short) + } + if exp.Short != nil && field.Short == nil { + t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, field.Short) + } + if exp.Short != nil && *exp.Short != *field.Short { + t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, *field.Short) + } + } +} + +func TestSlackActionsValidation(t *testing.T) { + in := ` +actions: +- type: button + text: hello + url: https://localhost + style: danger +- type: button + text: hello + name: something + style: default + confirm: + title: please confirm + text: are you sure? + ok_text: yes + dismiss_text: no +` + expected := []*SlackAction{ + { + Type: "button", + Text: "hello", + URL: "https://localhost", + Style: "danger", + }, + { + Type: "button", + Text: "hello", + Name: "something", + Style: "default", + ConfirmField: &SlackConfirmationField{ + Title: "please confirm", + Text: "are you sure?", + OkText: "yes", + DismissText: "no", + }, + }, + } + + var cfg SlackConfig + err := yaml.UnmarshalStrict([]byte(in), &cfg) + if err != nil { + t.Fatalf("\nerror returned when none expected, error:\n%v", err) + } + + for index, action := range cfg.Actions { + exp := expected[index] + if action.Type != exp.Type { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Type, action.Type) + } + if action.Text != exp.Text { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Text, action.Text) + } + if action.URL != exp.URL { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.URL, action.URL) + } + if action.Style != exp.Style { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Style, action.Style) + } + if action.Name != exp.Name { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Name, action.Name) + } + if action.Value != exp.Value { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, action.Value) + } + if action.ConfirmField != nil && exp.ConfirmField == nil || action.ConfirmField == nil && exp.ConfirmField != nil { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField, action.ConfirmField) + } else if action.ConfirmField != nil && exp.ConfirmField != nil { + if action.ConfirmField.Title != exp.ConfirmField.Title { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Title, action.ConfirmField.Title) + } + if action.ConfirmField.Text != exp.ConfirmField.Text { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Text, action.ConfirmField.Text) + } + if action.ConfirmField.OkText != exp.ConfirmField.OkText { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.OkText, action.ConfirmField.OkText) + } + if action.ConfirmField.DismissText != exp.ConfirmField.DismissText { + t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.DismissText, action.ConfirmField.DismissText) + } + } + } +} + +func newBoolPointer(b bool) *bool { + return &b +} diff --git a/notify/slack/internal/apiurl/apiurl.go b/notify/slack/internal/apiurl/apiurl.go new file mode 100644 index 0000000000..29e4061965 --- /dev/null +++ b/notify/slack/internal/apiurl/apiurl.go @@ -0,0 +1,108 @@ +// Copyright The Prometheus Authors +// 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 apiurl resolves Slack notifier api_url / api_url_file into outbound HTTP URLs. +package apiurl + +import ( + "fmt" + "os" + "strings" + + amcommoncfg "github.com/prometheus/alertmanager/config/common" +) + +// Resolver turns Slack notifier config (api_url or api_url_file) into the actual HTTP +// URL for each outgoing request (initial post, chat.update, etc.). +type Resolver struct { + apiURL *amcommoncfg.SecretURL + apiURLFile string +} + +// NewResolver captures api_url / api_url_file from config when the notifier is built. +// +// APIURLFile is stored as a path only. The file is read from disk on every call to +// URLForMethod when apiURL is nil—there is no in-memory cache of the URL string. +// That way changes to the file (rotation, out-of-band updates) apply to the next +// notification without restarting Alertmanager, matching historical behavior. +func NewResolver(apiURL *amcommoncfg.SecretURL, apiURLFile string) *Resolver { + return &Resolver{apiURL: apiURL, apiURLFile: apiURLFile} +} + +func (r *Resolver) URLForMethod(method string) (string, error) { + if method == "" { + if r.apiURL != nil { + apiURLStr := r.apiURL.String() + if apiURLStr != "" { + return apiURLStr, nil + } + return "", fmt.Errorf("slack api url is empty") + } + + // Read api_url_file on each resolution; see New. + parsed, err := r.getURLFromFile() + if err != nil { + return "", err + } + return parsed.String(), nil + } + + var baseURL *amcommoncfg.SecretURL + if r.apiURL != nil { + baseURL = r.apiURL + } else { + // Read api_url_file on each resolution; see New. + parsed, err := r.getURLFromFile() + if err != nil { + return "", err + } + secret := amcommoncfg.SecretURL(*parsed) + baseURL = &secret + } + + return webAPIMethodURL(baseURL, method) +} + +func (r *Resolver) getURLFromFile() (*amcommoncfg.URL, error) { + content, err := os.ReadFile(r.apiURLFile) + if err != nil { + return nil, err + } + raw := strings.TrimSpace(string(content)) + return amcommoncfg.ParseURL(raw) +} + +// webAPIMethodURL returns a Slack Web API URL for the given method, using the same +// Scheme and host as postMessageURL. postMessageURL must be a URL whose path ends +// with a method name (e.g. .../api/chat.postMessage). +func webAPIMethodURL(postMessageURL *amcommoncfg.SecretURL, method string) (string, error) { + if postMessageURL == nil || postMessageURL.URL == nil { + return "", fmt.Errorf("slack api url is nil") + } + + // Work on a copy so we never mutate the original URL. + u := *postMessageURL.URL + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("slack api url %q is missing scheme or host", u.String()) + } + + pathWithoutTrailingSlash := strings.TrimSuffix(u.Path, "/") + lastSlashIndex := strings.LastIndex(pathWithoutTrailingSlash, "/") + if lastSlashIndex < 0 { + return "", fmt.Errorf("slack api url %q has no path segment to replace", u.String()) + } + u.Path = pathWithoutTrailingSlash[:lastSlashIndex+1] + method + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} diff --git a/notify/slack/internal/apiurl/apiurl_test.go b/notify/slack/internal/apiurl/apiurl_test.go new file mode 100644 index 0000000000..ac787ee0e5 --- /dev/null +++ b/notify/slack/internal/apiurl/apiurl_test.go @@ -0,0 +1,175 @@ +// Copyright The Prometheus Authors +// 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 apiurl + +import ( + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" + + amcommoncfg "github.com/prometheus/alertmanager/config/common" +) + +const ( + baseURL = "https://slack.com" + defaultURLWithAPIPath = baseURL + "/api/chat.postMessage" + defaultURLWithFilePath = baseURL + "/file/chat.postMessage" + + updateChatMethod = "chat.update" +) + +func TestResolver_URLForMethod_ValidScenarios(t *testing.T) { + t.Parallel() + + defaultAPIURLFile := createTempAPIURLFile(t, "test_url_for_method", defaultURLWithFilePath) + + tests := []struct { + name string + apiURL *amcommoncfg.SecretURL + apiURLFile string + method string + want string + }{ + { + name: "empty method with apiURL", + apiURL: getSecretURL(t, defaultURLWithAPIPath), + apiURLFile: "", + method: "", + want: defaultURLWithAPIPath, + }, + { + name: "empty method without apiURL but with apiURLFile", + apiURL: nil, + apiURLFile: defaultAPIURLFile.Name(), + method: "", + want: defaultURLWithFilePath, + }, + { + name: "empty method with apiURL and apiURLFile, should use apiURL", + apiURL: getSecretURL(t, defaultURLWithAPIPath), + apiURLFile: defaultAPIURLFile.Name(), + method: "", + want: defaultURLWithAPIPath, + }, + { + name: "method chat.update with apiURL", + apiURL: getSecretURL(t, defaultURLWithAPIPath), + apiURLFile: defaultAPIURLFile.Name(), + method: updateChatMethod, + want: baseURL + "/api/" + updateChatMethod, + }, + { + name: "method chat.update with apiURL with trailing slash", + apiURL: getSecretURL(t, defaultURLWithAPIPath+"/"), + apiURLFile: defaultAPIURLFile.Name(), + method: updateChatMethod, + want: baseURL + "/api/" + updateChatMethod, + }, + { + name: "method chat.update with apiURLFile", + apiURL: nil, + apiURLFile: defaultAPIURLFile.Name(), + method: updateChatMethod, + want: baseURL + "/file/" + updateChatMethod, + }, + { + name: "method chat.update with apiURLFile with empty spaces", + apiURL: nil, + apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_new_lines_and_spaces", defaultURLWithFilePath+"\n \n").Name(), + method: updateChatMethod, + want: baseURL + "/file/" + updateChatMethod, + }, + { + name: "method chat.update with apiURLFile with trailing slash", + apiURL: nil, + apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_trailing_slash", defaultURLWithFilePath+"/").Name(), + method: updateChatMethod, + want: baseURL + "/file/" + updateChatMethod, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolver := NewResolver(tt.apiURL, tt.apiURLFile) + + got, err := resolver.URLForMethod(tt.method) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResolver_URLForMethod_InvalidScenarios(t *testing.T) { + t.Parallel() + + invalidURL := amcommoncfg.SecretURL{ + URL: &url.URL{}, + } + tests := []struct { + name string + apiURL *amcommoncfg.SecretURL + apiURLFile string + method string + expectedErrorMsg string + }{ + { + name: "invalid URL", + apiURL: &invalidURL, + apiURLFile: "", + method: "", + expectedErrorMsg: "slack api url is empty", + }, + { + name: "no apiURL nor apiURLFile", + apiURL: nil, + apiURLFile: "unknown", + method: "", + expectedErrorMsg: "open unknown: no such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolver := NewResolver(tt.apiURL, tt.apiURLFile) + + _, err := resolver.URLForMethod(tt.method) + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrorMsg) + }) + } +} + +func getSecretURL(t *testing.T, raw string) *amcommoncfg.SecretURL { + t.Helper() + u, err := amcommoncfg.ParseURL(raw) + require.NoError(t, err) + s := amcommoncfg.SecretURL(*u) + return &s +} + +func createTempAPIURLFile(t *testing.T, pattern, url string) *os.File { + t.Helper() + apiURLFileWithNewLines, err := os.CreateTemp(t.TempDir(), pattern) + require.NoError(t, err) + _, err = apiURLFileWithNewLines.WriteString(url) + require.NoError(t, err) + require.NoError(t, apiURLFileWithNewLines.Close()) + return apiURLFileWithNewLines +} diff --git a/notify/slack/slack.go b/notify/slack/slack.go index dbcea63dd7..5696ec9fbe 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -21,14 +21,13 @@ import ( "io" "log/slog" "net/http" - "os" "strings" commoncfg "github.com/prometheus/common/config" - "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/slack/internal/apiurl" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) @@ -36,19 +35,8 @@ import ( // https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. const maxTitleLenRunes = 1024 -// Notifier implements a Notifier for Slack notifications. -type Notifier struct { - conf *config.SlackConfig - tmpl *template.Template - logger *slog.Logger - client *http.Client - retrier *notify.Retrier - - postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) -} - // New returns a new Slack notification handler. -func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { +func New(c *SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...) if err != nil { return nil, err @@ -60,47 +48,11 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts . logger: l, client: client, retrier: ¬ify.Retrier{}, + urlResolver: apiurl.NewResolver(c.APIURL, c.APIURLFile), postJSONFunc: notify.PostJSON, }, nil } -// request is the request for sending a slack notification. -type request struct { - Channel string `json:"channel,omitempty"` - Timestamp string `json:"ts,omitempty"` - Username string `json:"username,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty"` - IconURL string `json:"icon_url,omitempty"` - LinkNames bool `json:"link_names,omitempty"` - Text string `json:"text,omitempty"` - Attachments []attachment `json:"attachments"` -} - -// attachment is used to display a richly-formatted message block. -type attachment struct { - Title string `json:"title,omitempty"` - TitleLink string `json:"title_link,omitempty"` - Pretext string `json:"pretext,omitempty"` - Text string `json:"text"` - Fallback string `json:"fallback"` - CallbackID string `json:"callback_id"` - Fields []config.SlackField `json:"fields,omitempty"` - Actions []config.SlackAction `json:"actions,omitempty"` - ImageURL string `json:"image_url,omitempty"` - ThumbURL string `json:"thumb_url,omitempty"` - Footer string `json:"footer"` - Color string `json:"color,omitempty"` - MrkdwnIn []string `json:"mrkdwn_in,omitempty"` -} - -// slackResponse represents the response from Slack API. -type slackResponse struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Channel string `json:"channel,omitempty"` - Timestamp string `json:"ts,omitempty"` -} - // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var err error @@ -143,7 +95,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) numFields := len(n.conf.Fields) if numFields > 0 { - fields := make([]config.SlackField, numFields) + fields := make([]SlackField, numFields) for index, field := range n.conf.Fields { // Check if short was defined for the field otherwise fallback to the global setting var short bool @@ -154,7 +106,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) } // Rebuild the field by executing any templates and setting the new value for short - fields[index] = config.SlackField{ + fields[index] = SlackField{ Title: tmplText(field.Title), Value: tmplText(field.Value), Short: &short, @@ -165,9 +117,9 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) numActions := len(n.conf.Actions) if numActions > 0 { - actions := make([]config.SlackAction, numActions) + actions := make([]SlackAction, numActions) for index, action := range n.conf.Actions { - slackAction := config.SlackAction{ + slackAction := SlackAction{ Type: tmplText(action.Type), Text: tmplText(action.Text), URL: tmplText(action.URL), @@ -177,7 +129,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) } if action.ConfirmField != nil { - slackAction.ConfirmField = &config.SlackConfirmationField{ + slackAction.ConfirmField = &SlackConfirmationField{ Title: tmplText(action.ConfirmField.Title), Text: tmplText(action.ConfirmField.Text), OkText: tmplText(action.ConfirmField.OkText), @@ -190,15 +142,9 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) att.Actions = actions } - var u string - if n.conf.APIURL != nil { - u = n.conf.APIURL.String() - } else { - content, err := os.ReadFile(n.conf.APIURLFile) - if err != nil { - return false, err - } - u = strings.TrimSpace(string(content)) + u, err := n.urlResolver.URLForMethod("") + if err != nil { + return false, err } if n.conf.Timeout > 0 { @@ -231,7 +177,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) channelId, _ := store.GetStr("channelId") logger.Debug("attempt recovering threadTs and channelId to update an existing message", "threadTs", threadTs, "channelId", channelId) if threadTs != "" && channelId != "" { - u = "https://slack.com/api/chat.update" + updateURL, err := n.urlResolver.URLForMethod("chat.update") + if err != nil { + return false, err + } + u = updateURL req.Timestamp = threadTs req.Channel = channelId logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId) diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 68924f7075..4a00f4b24f 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -21,7 +21,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "strings" "testing" "time" @@ -33,7 +32,6 @@ import ( amcommoncfg "github.com/prometheus/alertmanager/config/common" - "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" @@ -42,7 +40,7 @@ import ( func TestSlackRetry(t *testing.T) { notifier, err := New( - &config.SlackConfig{ + &SlackConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), @@ -61,7 +59,7 @@ func TestSlackRedactedURL(t *testing.T) { defer fn() notifier, err := New( - &config.SlackConfig{ + &SlackConfig{ APIURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, @@ -73,50 +71,6 @@ func TestSlackRedactedURL(t *testing.T) { test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } -func TestGettingSlackURLFromFile(t *testing.T) { - ctx, u, fn := test.GetContextWithCancelingURL() - defer fn() - - f, err := os.CreateTemp(t.TempDir(), "slack_test") - require.NoError(t, err, "creating temp file failed") - _, err = f.WriteString(u.String()) - require.NoError(t, err, "writing to temp file failed") - - notifier, err := New( - &config.SlackConfig{ - APIURLFile: f.Name(), - HTTPConfig: &commoncfg.HTTPClientConfig{}, - }, - test.CreateTmpl(t), - promslog.NewNopLogger(), - ) - require.NoError(t, err) - - test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) -} - -func TestTrimmingSlackURLFromFile(t *testing.T) { - ctx, u, fn := test.GetContextWithCancelingURL() - defer fn() - - f, err := os.CreateTemp(t.TempDir(), "slack_test_newline") - require.NoError(t, err, "creating temp file failed") - _, err = f.WriteString(u.String() + "\n\n") - require.NoError(t, err, "writing to temp file failed") - - notifier, err := New( - &config.SlackConfig{ - APIURLFile: f.Name(), - HTTPConfig: &commoncfg.HTTPClientConfig{}, - }, - test.CreateTmpl(t), - promslog.NewNopLogger(), - ) - require.NoError(t, err) - - test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) -} - func TestNotifier_Notify_WithReason(t *testing.T) { tests := []struct { name string @@ -196,7 +150,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) { t.Run(tt.name, func(t *testing.T) { apiurl, _ := url.Parse("https://slack.com/post.Message") notifier, err := New( - &config.SlackConfig{ + &SlackConfig{ NotifierConfig: amcommoncfg.NotifierConfig{}, HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.SecretURL{URL: apiurl}, @@ -254,7 +208,7 @@ func TestSlackTimeout(t *testing.T) { t.Run(name, func(t *testing.T) { u, _ := url.Parse("https://slack.com/post.Message") notifier, err := New( - &config.SlackConfig{ + &SlackConfig{ NotifierConfig: amcommoncfg.NotifierConfig{}, HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.SecretURL{URL: u}, @@ -325,7 +279,7 @@ func TestSlackMessageField(t *testing.T) { // 4. Configure Notifier with BOTH new and old fields u, _ := url.Parse(server.URL) - conf := &config.SlackConfig{ + conf := &SlackConfig{ APIURL: &amcommoncfg.SecretURL{URL: u}, MessageText: "My Top Level Message", // Your NEW field Title: "Old Attachment Title", // An OLD field diff --git a/notify/slack/types.go b/notify/slack/types.go new file mode 100644 index 0000000000..550fc7d595 --- /dev/null +++ b/notify/slack/types.go @@ -0,0 +1,74 @@ +// Copyright The Prometheus Authors +// 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 slack + +import ( + "context" + "io" + "log/slog" + "net/http" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/slack/internal/apiurl" + "github.com/prometheus/alertmanager/template" +) + +// Notifier implements a Notifier for Slack notifications. +type Notifier struct { + conf *SlackConfig + tmpl *template.Template + logger *slog.Logger + client *http.Client + retrier *notify.Retrier + urlResolver *apiurl.Resolver + + postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) +} + +// request is the request for sending a Slack notification. +type request struct { + Channel string `json:"channel,omitempty"` + Timestamp string `json:"ts,omitempty"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + LinkNames bool `json:"link_names,omitempty"` + Text string `json:"text,omitempty"` + Attachments []attachment `json:"attachments"` +} + +// attachment is used to display a richly formatted message block. +type attachment struct { + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Pretext string `json:"pretext,omitempty"` + Text string `json:"text"` + Fallback string `json:"fallback"` + CallbackID string `json:"callback_id"` + Fields []SlackField `json:"fields,omitempty"` + Actions []SlackAction `json:"actions,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + Footer string `json:"footer"` + Color string `json:"color,omitempty"` + MrkdwnIn []string `json:"mrkdwn_in,omitempty"` +} + +// slackResponse represents the response from Slack API. +type slackResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Channel string `json:"channel,omitempty"` + Timestamp string `json:"ts,omitempty"` +}