diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 0000000000..8bf7cc27a1 --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.gitignore b/.gitignore index 1997d9b419..32635e3cdc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /amtool /data/ /vendor +.idea diff --git a/notify/slack/internal/apiurl/apiurl.go b/notify/slack/internal/apiurl/apiurl.go new file mode 100644 index 0000000000..62660acecb --- /dev/null +++ b/notify/slack/internal/apiurl/apiurl.go @@ -0,0 +1,108 @@ +// Copyright 2019 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package 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..f4651edcf8 --- /dev/null +++ b/notify/slack/internal/apiurl/apiurl_test.go @@ -0,0 +1,176 @@ +// 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" + + addReactionsMethod = "reactions.add" + 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 reactions.add with apiURLFile", + apiURL: nil, + apiURLFile: defaultAPIURLFile.Name(), + method: addReactionsMethod, + want: baseURL + "/file/" + addReactionsMethod, + }, + { + name: "method reactions.add with apiURLFile with empty spaces", + apiURL: nil, + apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_new_lines_and_spaces", defaultURLWithFilePath+"\n \n").Name(), + method: addReactionsMethod, + want: baseURL + "/file/" + addReactionsMethod, + }, + { + name: "method reactions.add with apiURLFile with trailing slash", + apiURL: nil, + apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_trailing_slash", defaultURLWithFilePath+"/").Name(), + method: addReactionsMethod, + want: baseURL + "/file/" + addReactionsMethod, + }, + } + + 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..705fd4cecf 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -21,7 +21,6 @@ import ( "io" "log/slog" "net/http" - "os" "strings" commoncfg "github.com/prometheus/common/config" @@ -29,6 +28,7 @@ import ( "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,17 +36,6 @@ 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) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...) @@ -60,47 +49,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 @@ -190,15 +143,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 +178,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..490e4a1f0f 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" @@ -73,50 +72,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 diff --git a/notify/slack/types.go b/notify/slack/types.go new file mode 100644 index 0000000000..2f2d2a4e09 --- /dev/null +++ b/notify/slack/types.go @@ -0,0 +1,75 @@ +// Copyright 2019 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "context" + "io" + "log/slog" + "net/http" + + "github.com/prometheus/alertmanager/config" + "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 *config.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 []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"` +}