diff --git a/config/notifiers.go b/config/notifiers.go index a9052475d5..fbf59eb2e9 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -328,23 +328,25 @@ type SlackConfig struct { 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"` + 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"` + BlocKitEnabeld *bool `yaml:"use_block_kit,omitempty" json:"use_block_kit,omitempty"` + BlocKitPayload any `yaml:"block_kit_payload,omitempty" json:"block_kit_payload,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. diff --git a/docs/configuration.md b/docs/configuration.md index be9a73f841..ae681e08d5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1743,6 +1743,20 @@ fields: # Enables updating existing Slack messages instead of creating new ones on alert state change. # Webhook URLs do not support updates. [ update_message: | default = false ] + +# Enables Slack Block Kit layout instead of the legacy attachment format. +[ use_block_kit: | default = false ] + +# A list of Block Kit block objects rendered as the 'blocks' field of the Slack API payload. +# Template expressions in string values are expanded with the same data available to other +# tmpl_string fields. The channel and fallback text come from the top-level 'channel' and +# 'message_text' fields respectively. +# See https://api.slack.com/reference/block-kit/blocks for the block schema. +# NOTE: String values that render to a YAML/JSON mapping (e.g. "key: value") are parsed into +# a map structure by the template engine. Avoid bare 'key: value' patterns in rendered text; +# use a different separator or quote the value in the template if needed. +block_kit_payload: + [ - , ... ] ``` #### `` (Slack) @@ -1782,6 +1796,35 @@ value: [ short: | default = slack_config.short_fields ] ``` +#### Block Kit example + +When `use_block_kit: true` the `block_kit_payload` field accepts a list of [Block Kit block objects](https://api.slack.com/reference/block-kit/blocks). The `channel` and fallback `message_text` are set at the top level of the receiver config as usual. + +```yaml +slack_configs: + - api_url: 'https://slack.com/api/chat.postMessage' + http_config: + authorization: + credentials: '' + channel: '#alerts' + message_text: 'fallback string' + use_block_kit: true + block_kit_payload: + - type: header + text: + type: plain_text + text: '{{ .CommonAnnotations.SortedPairs.Values | join " " | printf "%q" }}' + - type: section + text: + type: mrkdwn + text: |- + *Alert:* {{ .CommonAnnotations.description }} - Severity: {{ .CommonLabels.severity_id }} + + *Description:* {{ .CommonAnnotations.description }} +``` + +> **Note:** String values inside `block_kit_payload` are rendered as Go templates and then passed through a YAML parser. A rendered value that matches the `key: value` pattern (colon followed by a space) will be interpreted as a YAML mapping and converted to a map rather than remaining a plain string. To keep such text as a string, avoid the bare `key: value` pattern in rendered output or use a template that wraps the value in quotes (e.g. `{{ .Value | printf "%q" }}`). + ### `` ```yaml diff --git a/notify/slack/message_blockkit.go b/notify/slack/message_blockkit.go new file mode 100644 index 0000000000..8d2681f3a0 --- /dev/null +++ b/notify/slack/message_blockkit.go @@ -0,0 +1,46 @@ +// 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 ( + "fmt" + + "github.com/prometheus/alertmanager/template" +) + +func composeBlockKitPayload(blocksTmpl any, channel, text string, tmplText func(string) string, tmplTextErr *error) (map[string]any, error) { + tmplTextFunc := func(tmpl string) (string, error) { + return tmplText(tmpl), *tmplTextErr + } + + renderedBlocks, err := template.DeepCopyWithTemplate(blocksTmpl, tmplTextFunc) + if err != nil { + return nil, err + } + + return map[string]any{ + "channel": channel, + "text": text, + "blocks": renderedBlocks, + }, nil +} + +func setBlockKitPayloadStringValue(payload any, key, value string) error { + payloadMap, ok := payload.(map[string]any) + if !ok { + return fmt.Errorf("block_kit_payload must render to an object, got %T", payload) + } + payloadMap[key] = value + return nil +} diff --git a/notify/slack/message_blockkit_test.go b/notify/slack/message_blockkit_test.go new file mode 100644 index 0000000000..e7a5d4a60a --- /dev/null +++ b/notify/slack/message_blockkit_test.go @@ -0,0 +1,66 @@ +// 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/test" + "github.com/prometheus/alertmanager/template" +) + +func TestComposeBlockKitPayload(t *testing.T) { + blocksTmpl := []any{ + map[string]any{ + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": `*Summary:* {{ index .CommonAnnotations "summary" }}`, + }, + }, + } + + tmpl := test.CreateTmpl(t) + data := &template.Data{ + Status: "firing", + CommonAnnotations: template.KV{"summary": "CPU usage above 90 percent"}, + } + var tmplTextErr error + tmplText := notify.TmplText(tmpl, data, &tmplTextErr) + + payload, err := composeBlockKitPayload(blocksTmpl, "#alerts-channel", tmplText(`{{ .Status }}`), tmplText, &tmplTextErr) + require.NoError(t, err) + require.NoError(t, tmplTextErr) + + encoded, err := json.Marshal(payload) + require.NoError(t, err) + + require.JSONEq(t, `{ + "channel": "#alerts-channel", + "text": "firing", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Summary:* CPU usage above 90 percent" + } + } + ] + }`, string(encoded)) +} diff --git a/notify/slack/message_plain.go b/notify/slack/message_plain.go new file mode 100644 index 0000000000..94a1bd729a --- /dev/null +++ b/notify/slack/message_plain.go @@ -0,0 +1,112 @@ +// 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 ( + "log/slog" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" +) + +// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. +const maxTitleLenRunes = 1024 + +// composePlainRequest builds the current attachment-based Slack payload. +func composePlainRequest(c *config.SlackConfig, tmplText func(string) string, logger *slog.Logger) *request { + var markdownIn []string + if len(c.MrkdwnIn) == 0 { + markdownIn = []string{"fallback", "pretext", "text"} + } else { + markdownIn = c.MrkdwnIn + } + + title, truncated := notify.TruncateInRunes(tmplText(c.Title), maxTitleLenRunes) + if truncated { + logger.Warn("Truncated title", "max_runes", maxTitleLenRunes) + } + + att := attachment{ + Title: title, + TitleLink: tmplText(c.TitleLink), + Pretext: tmplText(c.Pretext), + Text: tmplText(c.Text), + Fallback: tmplText(c.Fallback), + CallbackID: tmplText(c.CallbackID), + ImageURL: tmplText(c.ImageURL), + ThumbURL: tmplText(c.ThumbURL), + Footer: tmplText(c.Footer), + Color: tmplText(c.Color), + MrkdwnIn: markdownIn, + } + + numFields := len(c.Fields) + if numFields > 0 { + fields := make([]config.SlackField, numFields) + for index, field := range c.Fields { + // Check if short was defined for the field otherwise fallback to the global setting. + var short bool + if field.Short != nil { + short = *field.Short + } else { + short = c.ShortFields + } + + // Rebuild the field by executing templates and preserving short semantics. + fields[index] = config.SlackField{ + Title: tmplText(field.Title), + Value: tmplText(field.Value), + Short: &short, + } + } + att.Fields = fields + } + + numActions := len(c.Actions) + if numActions > 0 { + actions := make([]config.SlackAction, numActions) + for index, action := range c.Actions { + slackAction := config.SlackAction{ + Type: tmplText(action.Type), + Text: tmplText(action.Text), + URL: tmplText(action.URL), + Style: tmplText(action.Style), + Name: tmplText(action.Name), + Value: tmplText(action.Value), + } + + if action.ConfirmField != nil { + slackAction.ConfirmField = &config.SlackConfirmationField{ + Title: tmplText(action.ConfirmField.Title), + Text: tmplText(action.ConfirmField.Text), + OkText: tmplText(action.ConfirmField.OkText), + DismissText: tmplText(action.ConfirmField.DismissText), + } + } + + actions[index] = slackAction + } + att.Actions = actions + } + + return &request{ + Channel: tmplText(c.Channel), + Username: tmplText(c.Username), + IconEmoji: tmplText(c.IconEmoji), + IconURL: tmplText(c.IconURL), + LinkNames: c.LinkNames, + Text: tmplText(c.MessageText), + Attachments: []attachment{att}, + } +} diff --git a/notify/slack/slack.go b/notify/slack/slack.go index f96efe61fa..402adf9339 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -33,9 +33,6 @@ import ( "github.com/prometheus/alertmanager/types" ) -// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. -const maxTitleLenRunes = 1024 - // New returns a new Slack notification handler. func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...) @@ -64,83 +61,10 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) logger.Debug("extracted group key") var ( - data = notify.GetTemplateData(ctx, n.tmpl, as, logger) - tmplText = notify.TmplText(n.tmpl, data, &err) + data = notify.GetTemplateData(ctx, n.tmpl, as, logger) + tmplTextErr error + tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr) ) - var markdownIn []string - - if len(n.conf.MrkdwnIn) == 0 { - markdownIn = []string{"fallback", "pretext", "text"} - } else { - markdownIn = n.conf.MrkdwnIn - } - - title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes) - if truncated { - logger.Warn("Truncated title", "max_runes", maxTitleLenRunes) - } - att := &attachment{ - Title: title, - TitleLink: tmplText(n.conf.TitleLink), - Pretext: tmplText(n.conf.Pretext), - Text: tmplText(n.conf.Text), - Fallback: tmplText(n.conf.Fallback), - CallbackID: tmplText(n.conf.CallbackID), - ImageURL: tmplText(n.conf.ImageURL), - ThumbURL: tmplText(n.conf.ThumbURL), - Footer: tmplText(n.conf.Footer), - Color: tmplText(n.conf.Color), - MrkdwnIn: markdownIn, - } - - numFields := len(n.conf.Fields) - if numFields > 0 { - fields := make([]config.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 - if field.Short != nil { - short = *field.Short - } else { - short = n.conf.ShortFields - } - - // Rebuild the field by executing any templates and setting the new value for short - fields[index] = config.SlackField{ - Title: tmplText(field.Title), - Value: tmplText(field.Value), - Short: &short, - } - } - att.Fields = fields - } - - numActions := len(n.conf.Actions) - if numActions > 0 { - actions := make([]config.SlackAction, numActions) - for index, action := range n.conf.Actions { - slackAction := config.SlackAction{ - Type: tmplText(action.Type), - Text: tmplText(action.Text), - URL: tmplText(action.URL), - Style: tmplText(action.Style), - Name: tmplText(action.Name), - Value: tmplText(action.Value), - } - - if action.ConfirmField != nil { - slackAction.ConfirmField = &config.SlackConfirmationField{ - Title: tmplText(action.ConfirmField.Title), - Text: tmplText(action.ConfirmField.Text), - OkText: tmplText(action.ConfirmField.OkText), - DismissText: tmplText(action.ConfirmField.DismissText), - } - } - - actions[index] = slackAction - } - att.Actions = actions - } var u string if n.conf.APIURL != nil { @@ -159,15 +83,21 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) ctx = postCtx } - req := &request{ - Channel: tmplText(n.conf.Channel), - Username: tmplText(n.conf.Username), - IconEmoji: tmplText(n.conf.IconEmoji), - IconURL: tmplText(n.conf.IconURL), - LinkNames: n.conf.LinkNames, - Text: tmplText(n.conf.MessageText), - Attachments: []attachment{*att}, + useBlockKit := n.conf.BlocKitEnabeld != nil && *n.conf.BlocKitEnabeld + req := composePlainRequest(n.conf, tmplText, logger) + payload := any(req) + channelForError := req.Channel + var bkPayload map[string]any + + if useBlockKit { + bkPayload, err = composeBlockKitPayload(n.conf.BlocKitPayload, tmplText(n.conf.Channel), tmplText(n.conf.MessageText), tmplText, &tmplTextErr) + if err != nil { + return false, fmt.Errorf("failed to render block kit payload: %w", err) + } + payload = bkPayload + channelForError = tmplText(n.conf.Channel) } + logger.Debug("payload", "payload", payload) // If a notification for this alert group has already been sent and `update_message` config is set // edit API endpoint and payload to update notification instead of sending a new one. @@ -184,14 +114,25 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) 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" - req.Timestamp = threadTs - req.Channel = channelId + if useBlockKit { + if err := setBlockKitPayloadStringValue(bkPayload, "ts", threadTs); err != nil { + return false, fmt.Errorf("cannot set ts in block kit payload: %w", err) + } + if err := setBlockKitPayloadStringValue(bkPayload, "channel", channelId); err != nil { + return false, fmt.Errorf("cannot set channel in block kit payload: %w", err) + } + channelForError = channelId + } else { + req.Timestamp = threadTs + req.Channel = channelId + channelForError = req.Channel + } logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId) } } } var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(req); err != nil { + if err := json.NewEncoder(&buf).Encode(payload); err != nil { return false, err } @@ -208,13 +149,13 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) // classify them as retriable or not. retry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { - err = fmt.Errorf("channel %q: %w", req.Channel, err) + err = fmt.Errorf("channel %q: %w", channelForError, err) return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } retry, err = n.slackResponseHandler(resp, store) if err != nil { - err = fmt.Errorf("channel %q: %w", req.Channel, err) + err = fmt.Errorf("channel %q: %w", channelForError, err) return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err) } return retry, nil diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 9987f21670..baee10ec17 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -352,3 +352,87 @@ func TestSlackMessageField(t *testing.T) { t.Fatal("Notify failed:", err) } } + +func TestSlackBlockKitPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + + if got := body["channel"]; got != "#block-kit" { + t.Errorf("expected channel %q, got %v", "#block-kit", got) + } + if got := body["text"]; got != "BlockKit resolved" { + t.Errorf("expected text %q, got %v", "BlockKit resolved", got) + } + + blocks, ok := body["blocks"].([]any) + if !ok { + t.Errorf("expected blocks array in payload") + } else if len(blocks) != 1 { + t.Errorf("expected one block, got %d", len(blocks)) + } + + if _, hasAttachments := body["attachments"]; hasAttachments { + t.Errorf("did not expect legacy attachments in block kit payload") + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + useBlockKit := true + u, _ := url.Parse(server.URL) + conf := &config.SlackConfig{ + APIURL: &amcommoncfg.SecretURL{URL: u}, + Channel: "#block-kit", + MessageText: "BlockKit {{ .Status }}", + BlocKitEnabeld: &useBlockKit, + BlocKitPayload: []any{ + map[string]any{ + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": "*Alert payload from Block Kit*", + }, + }, + }, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + tmpl.ExternalURL = u + + notifier, err := New(conf, tmpl, promslog.NewNopLogger()) + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "group-1") + _, err = notifier.Notify(ctx) + require.NoError(t, err) +} + +func TestSlackBlockKitPayloadInvalid(t *testing.T) { + useBlockKit := true + u, _ := url.Parse("https://slack.com/api/chat.postMessage") + notifier, err := New( + &config.SlackConfig{ + APIURL: &amcommoncfg.SecretURL{URL: u}, + Channel: "#channelname", + BlocKitEnabeld: &useBlockKit, + BlocKitPayload: `{"text": "{{ if }}`, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + test.CreateTmpl(t), + promslog.NewNopLogger(), + ) + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "1") + _, err = notifier.Notify(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to render block kit payload") +} diff --git a/template/default.tmpl b/template/default.tmpl index fa7828f6f2..6e6eecaa49 100644 --- a/template/default.tmpl +++ b/template/default.tmpl @@ -233,4 +233,6 @@ Alerts Resolved: # Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} -{{ end }} \ No newline at end of file +{{ end }} + +