Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
plans/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
/amtool
/data/
/vendor
.idea
108 changes: 108 additions & 0 deletions notify/slack/internal/apiurl/apiurl.go
Original file line number Diff line number Diff line change
@@ -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
}
176 changes: 176 additions & 0 deletions notify/slack/internal/apiurl/apiurl_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading