Skip to content

Commit 7976328

Browse files
committed
refactor: move slack url creation to its own package
Signed-off-by: Santiago Fernández Núñez <santiago.nunez@nubank.com.br> Made-with: Cursor
1 parent 4691104 commit 7976328

5 files changed

Lines changed: 304 additions & 61 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2019 Prometheus Team
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
// Package apiurl resolves Slack notifier api_url / api_url_file into outbound HTTP URLs.
15+
package apiurl
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"strings"
21+
22+
amcommoncfg "github.com/prometheus/alertmanager/config/common"
23+
)
24+
25+
// Resolver turns Slack notifier config (api_url or api_url_file) into the actual HTTP
26+
// URL for each outgoing request (initial post, chat.update, etc.).
27+
type Resolver struct {
28+
apiURL *amcommoncfg.SecretURL
29+
apiURLFile string
30+
}
31+
32+
// New captures api_url / api_url_file from config when the notifier is built.
33+
//
34+
// APIURLFile is stored as a path only. The file is read from disk on every call to
35+
// URLForMethod when apiURL is nil—there is no in-memory cache of the URL string.
36+
// That way changes to the file (rotation, out-of-band updates) apply to the next
37+
// notification without restarting Alertmanager, matching historical behavior.
38+
func New(apiURL *amcommoncfg.SecretURL, apiURLFile string) *Resolver {
39+
return &Resolver{apiURL: apiURL, apiURLFile: apiURLFile}
40+
}
41+
42+
func (r *Resolver) URLForMethod(method string) (string, error) {
43+
if method == "" {
44+
if r.apiURL != nil {
45+
apiURLStr := r.apiURL.String()
46+
if apiURLStr != "" {
47+
return apiURLStr, nil
48+
} else {
49+
return "", fmt.Errorf("slack api url is empty")
50+
}
51+
}
52+
53+
// Read api_url_file on each resolution; see New.
54+
parsed, err := r.getURLFromFile()
55+
if err != nil {
56+
return "", err
57+
}
58+
return parsed.String(), nil
59+
}
60+
61+
var baseURL *amcommoncfg.SecretURL
62+
if r.apiURL != nil {
63+
baseURL = r.apiURL
64+
} else {
65+
// Read api_url_file on each resolution; see New.
66+
parsed, err := r.getURLFromFile()
67+
if err != nil {
68+
return "", err
69+
}
70+
secret := amcommoncfg.SecretURL(*parsed)
71+
baseURL = &secret
72+
}
73+
74+
return webAPIMethodURL(baseURL, method)
75+
}
76+
77+
func (r *Resolver) getURLFromFile() (*amcommoncfg.URL, error) {
78+
content, err := os.ReadFile(r.apiURLFile)
79+
if err != nil {
80+
return nil, err
81+
}
82+
raw := strings.TrimSpace(string(content))
83+
return amcommoncfg.ParseURL(raw)
84+
}
85+
86+
// webAPIMethodURL returns a Slack Web API URL for the given method, using the same
87+
// scheme and host as postMessageURL. postMessageURL must be a URL whose path ends
88+
// with a method name (e.g. .../api/chat.postMessage).
89+
func webAPIMethodURL(postMessageURL *amcommoncfg.SecretURL, method string) (string, error) {
90+
if postMessageURL == nil || postMessageURL.URL == nil {
91+
return "", fmt.Errorf("slack api url is nil")
92+
}
93+
94+
// Work on a copy so we never mutate the original URL.
95+
u := *postMessageURL.URL
96+
if u.Scheme == "" || u.Host == "" {
97+
return "", fmt.Errorf("slack api url %q is missing scheme or host", u.String())
98+
}
99+
100+
pathWithoutTrailingSlash := strings.TrimSuffix(u.Path, "/")
101+
lastSlashIndex := strings.LastIndex(pathWithoutTrailingSlash, "/")
102+
if lastSlashIndex < 0 {
103+
return "", fmt.Errorf("slack api url %q has no path segment to replace", u.String())
104+
}
105+
u.Path = pathWithoutTrailingSlash[:lastSlashIndex+1] + method
106+
u.RawQuery = ""
107+
u.Fragment = ""
108+
return u.String(), nil
109+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package apiurl
15+
16+
import (
17+
"net/url"
18+
"os"
19+
"testing"
20+
21+
"github.com/stretchr/testify/require"
22+
23+
amcommoncfg "github.com/prometheus/alertmanager/config/common"
24+
)
25+
26+
const (
27+
baseURL = "https://slack.com"
28+
defaultURLWithAPIPath = baseURL + "/api/chat.postMessage"
29+
defaultURLWithFilePath = baseURL + "/file/chat.postMessage"
30+
31+
addReactionsMethod = "reactions.add"
32+
updateChatMethod = "chat.update"
33+
)
34+
35+
func TestResolver_URLForMethod_ValidScenarios(t *testing.T) {
36+
t.Parallel()
37+
38+
defaultAPIURLFile := createTempAPIURLFile(t, "test_url_for_method", defaultURLWithFilePath)
39+
40+
tests := []struct {
41+
name string
42+
apiURL *amcommoncfg.SecretURL
43+
apiURLFile string
44+
method string
45+
want string
46+
}{
47+
{
48+
name: "empty method with apiURL",
49+
apiURL: getSecretURL(t, defaultURLWithAPIPath),
50+
apiURLFile: "",
51+
method: "",
52+
want: defaultURLWithAPIPath,
53+
},
54+
{
55+
name: "empty method without apiURL but with apiURLFile",
56+
apiURL: nil,
57+
apiURLFile: defaultAPIURLFile.Name(),
58+
method: "",
59+
want: defaultURLWithFilePath,
60+
},
61+
{
62+
name: "empty method with apiURL and apiURLFile, should use apiURL",
63+
apiURL: getSecretURL(t, defaultURLWithAPIPath),
64+
apiURLFile: defaultAPIURLFile.Name(),
65+
method: "",
66+
want: defaultURLWithAPIPath,
67+
},
68+
{
69+
name: "method chat.update with apiURL",
70+
apiURL: getSecretURL(t, defaultURLWithAPIPath),
71+
apiURLFile: defaultAPIURLFile.Name(),
72+
method: updateChatMethod,
73+
want: baseURL + "/api/" + updateChatMethod,
74+
},
75+
{
76+
name: "method chat.update with apiURL with trailing slash",
77+
apiURL: getSecretURL(t, defaultURLWithAPIPath+"/"),
78+
apiURLFile: defaultAPIURLFile.Name(),
79+
method: updateChatMethod,
80+
want: baseURL + "/api/" + updateChatMethod,
81+
},
82+
{
83+
name: "method reactions.add with apiURLFile",
84+
apiURL: nil,
85+
apiURLFile: defaultAPIURLFile.Name(),
86+
method: addReactionsMethod,
87+
want: baseURL + "/file/" + addReactionsMethod,
88+
},
89+
{
90+
name: "method reactions.add with apiURLFile with empty spaces",
91+
apiURL: nil,
92+
apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_new_lines_and_spaces", defaultURLWithFilePath+"\n \n").Name(),
93+
method: addReactionsMethod,
94+
want: baseURL + "/file/" + addReactionsMethod,
95+
},
96+
{
97+
name: "method reactions.add with apiURLFile with trailing slash",
98+
apiURL: nil,
99+
apiURLFile: createTempAPIURLFile(t, "test_url_for_method_with_trailing_slash", defaultURLWithFilePath+"/").Name(),
100+
method: addReactionsMethod,
101+
want: baseURL + "/file/" + addReactionsMethod,
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
tt := tt
107+
t.Run(tt.name, func(t *testing.T) {
108+
t.Parallel()
109+
110+
resolver := New(tt.apiURL, tt.apiURLFile)
111+
112+
got, err := resolver.URLForMethod(tt.method)
113+
require.NoError(t, err)
114+
require.Equal(t, tt.want, got)
115+
})
116+
}
117+
}
118+
119+
func TestResolver_URLForMethod_InvalidScenarios(t *testing.T) {
120+
t.Parallel()
121+
122+
invalidURL := amcommoncfg.SecretURL{
123+
URL: &url.URL{},
124+
}
125+
tests := []struct {
126+
name string
127+
apiURL *amcommoncfg.SecretURL
128+
apiURLFile string
129+
method string
130+
expectedErrorMsg string
131+
}{
132+
{
133+
name: "invalid URL",
134+
apiURL: &invalidURL,
135+
apiURLFile: "",
136+
method: "",
137+
expectedErrorMsg: "slack api url is empty",
138+
},
139+
{
140+
name: "no apiURL nor apiURLFile",
141+
apiURL: nil,
142+
apiURLFile: "unknown",
143+
method: "",
144+
expectedErrorMsg: "open unknown: no such file or directory",
145+
},
146+
}
147+
148+
for _, tt := range tests {
149+
tt := tt
150+
t.Run(tt.name, func(t *testing.T) {
151+
t.Parallel()
152+
153+
resolver := New(tt.apiURL, tt.apiURLFile)
154+
155+
_, err := resolver.URLForMethod(tt.method)
156+
require.Error(t, err)
157+
require.Contains(t, err.Error(), tt.expectedErrorMsg)
158+
})
159+
}
160+
}
161+
162+
func getSecretURL(t *testing.T, raw string) *amcommoncfg.SecretURL {
163+
t.Helper()
164+
u, err := amcommoncfg.ParseURL(raw)
165+
require.NoError(t, err)
166+
s := amcommoncfg.SecretURL(*u)
167+
return &s
168+
}
169+
170+
func createTempAPIURLFile(t *testing.T, pattern string, url string) *os.File {
171+
t.Helper()
172+
apiURLFileWithNewLines, err := os.CreateTemp(t.TempDir(), pattern)
173+
require.NoError(t, err)
174+
_, err = apiURLFileWithNewLines.WriteString(url)
175+
require.NoError(t, err)
176+
require.NoError(t, apiURLFileWithNewLines.Close())
177+
return apiURLFileWithNewLines
178+
}

notify/slack/slack.go

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ import (
2121
"io"
2222
"log/slog"
2323
"net/http"
24-
"os"
2524
"strings"
2625

2726
commoncfg "github.com/prometheus/common/config"
2827

2928
"github.com/prometheus/alertmanager/config"
3029
"github.com/prometheus/alertmanager/nflog"
3130
"github.com/prometheus/alertmanager/notify"
31+
"github.com/prometheus/alertmanager/notify/slack/internal/apiurl"
3232
"github.com/prometheus/alertmanager/template"
3333
"github.com/prometheus/alertmanager/types"
3434
)
@@ -49,6 +49,7 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts .
4949
logger: l,
5050
client: client,
5151
retrier: &notify.Retrier{},
52+
urlResolver: apiurl.New(c.APIURL, c.APIURLFile),
5253
postJSONFunc: notify.PostJSON,
5354
}, nil
5455
}
@@ -142,15 +143,9 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
142143
att.Actions = actions
143144
}
144145

145-
var u string
146-
if n.conf.APIURL != nil {
147-
u = n.conf.APIURL.String()
148-
} else {
149-
content, err := os.ReadFile(n.conf.APIURLFile)
150-
if err != nil {
151-
return false, err
152-
}
153-
u = strings.TrimSpace(string(content))
146+
u, err := n.urlResolver.URLForMethod("")
147+
if err != nil {
148+
return false, err
154149
}
155150

156151
if n.conf.Timeout > 0 {
@@ -183,7 +178,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
183178
channelId, _ := store.GetStr("channelId")
184179
logger.Debug("attempt recovering threadTs and channelId to update an existing message", "threadTs", threadTs, "channelId", channelId)
185180
if threadTs != "" && channelId != "" {
186-
u = "https://slack.com/api/chat.update"
181+
updateURL, err := n.urlResolver.URLForMethod("chat.update")
182+
if err != nil {
183+
return false, err
184+
}
185+
u = updateURL
187186
req.Timestamp = threadTs
188187
req.Channel = channelId
189188
logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId)

0 commit comments

Comments
 (0)