Alerting: Add search.folder filter to the Prometheus rules API (#114358)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -2790,6 +2791,101 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
require.NotEmpty(t, res.Data.NextToken)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with search.folder filter", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create folders with different paths
|
||||
folder1 := &folder.Folder{UID: "prod-uid", Title: "Production", Fullpath: "Production", OrgID: orgID}
|
||||
folder2 := &folder.Folder{UID: "prod-alerts-uid", Title: "Alerts", Fullpath: "Production/Alerts", OrgID: orgID}
|
||||
folder3 := &folder.Folder{UID: "dev-uid", Title: "Monitoring", Fullpath: "Development/Monitoring", OrgID: orgID}
|
||||
folder4 := &folder.Folder{UID: "prod-crit-uid", Title: "Critical", Fullpath: "Production/Critical", OrgID: orgID}
|
||||
fakeStore.Folders[orgID] = []*folder.Folder{folder1, folder2, folder3, folder4}
|
||||
|
||||
// Create rules in different folders
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithNamespaceUID("prod-uid"), gen.WithUID("rule1"), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithNamespaceUID("prod-alerts-uid"), gen.WithUID("rule2"), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithNamespaceUID("dev-uid"), gen.WithUID("rule3"), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithNamespaceUID("prod-crit-uid"), gen.WithUID("rule4"), gen.WithNoNotificationSettings())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
searchFolder string
|
||||
expectedUIDs []string
|
||||
}{
|
||||
{
|
||||
name: "search 'production' matches Production folders",
|
||||
searchFolder: "production",
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule4"},
|
||||
},
|
||||
{
|
||||
name: "search 'prod alerts' matches Production/Alerts",
|
||||
searchFolder: "prod alerts",
|
||||
expectedUIDs: []string{"rule2"},
|
||||
},
|
||||
{
|
||||
name: "search 'dev' matches Development",
|
||||
searchFolder: "dev",
|
||||
expectedUIDs: []string{"rule3"},
|
||||
},
|
||||
{
|
||||
name: "search 'critical' matches Critical folder",
|
||||
searchFolder: "critical",
|
||||
expectedUIDs: []string{"rule4"},
|
||||
},
|
||||
{
|
||||
name: "case insensitive search",
|
||||
searchFolder: "PRODUCTION",
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule4"},
|
||||
},
|
||||
{
|
||||
name: "empty search returns all rules",
|
||||
searchFolder: "",
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4"},
|
||||
},
|
||||
{
|
||||
name: "non-matching search returns no rules",
|
||||
searchFolder: "nonexistent",
|
||||
expectedUIDs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reqURL := "/api/v1/rules"
|
||||
if tc.searchFolder != "" {
|
||||
reqURL += "?search.folder=" + url.QueryEscape(tc.searchFolder)
|
||||
}
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
require.NoError(t, err)
|
||||
ctx := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(ctx)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
require.Equal(t, "success", res.Status)
|
||||
|
||||
// Collect rule UIDs from response
|
||||
actualUIDs := []string{}
|
||||
for _, group := range res.Data.RuleGroups {
|
||||
for _, rule := range group.Rules {
|
||||
actualUIDs = append(actualUIDs, rule.UID)
|
||||
}
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, tc.expectedUIDs, actualUIDs)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
|
||||
|
||||
@@ -689,9 +689,20 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
namespaceUIDs := make([]string, 0, len(opts.AllowedNamespaces))
|
||||
|
||||
folderUID := opts.Query.Get("folder_uid")
|
||||
searchFolder := opts.Query.Get("search.folder")
|
||||
|
||||
_, exists := opts.AllowedNamespaces[folderUID]
|
||||
if folderUID != "" && exists {
|
||||
// Exact folder UID match
|
||||
namespaceUIDs = append(namespaceUIDs, folderUID)
|
||||
} else if searchFolder != "" {
|
||||
// Search folders by full path
|
||||
matcher := NewTextMatcher(searchFolder)
|
||||
for uid, fullpath := range opts.AllowedNamespaces {
|
||||
if matcher.Match(fullpath) {
|
||||
namespaceUIDs = append(namespaceUIDs, uid)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for k := range opts.AllowedNamespaces {
|
||||
namespaceUIDs = append(namespaceUIDs, k)
|
||||
@@ -700,9 +711,15 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Bool("folder_uid_set", folderUID != ""),
|
||||
attribute.Bool("search_folder_set", searchFolder != ""),
|
||||
attribute.Int("namespace_count", len(namespaceUIDs)),
|
||||
)
|
||||
|
||||
if searchFolder != "" && len(namespaceUIDs) == 0 {
|
||||
log.Debug("No folders matched search.folder, returning empty response")
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
ruleGroups := opts.Query["rule_group"]
|
||||
ruleUIDs := opts.Query["rule_uid"]
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TextMatcher performs case-insensitive sequential substring matching.
|
||||
// It checks if text contains all search words in the order provided,
|
||||
// but not necessarily consecutively.
|
||||
//
|
||||
// Example: NewTextMatcher("api time").Match("API Response Time") returns true
|
||||
type TextMatcher struct {
|
||||
words []string
|
||||
}
|
||||
|
||||
func NewTextMatcher(search string) *TextMatcher {
|
||||
words := strings.Fields(search)
|
||||
for i := range words {
|
||||
words[i] = strings.ToLower(words[i])
|
||||
}
|
||||
return &TextMatcher{words: words}
|
||||
}
|
||||
|
||||
func (m *TextMatcher) Match(text string) bool {
|
||||
if len(m.words) == 0 {
|
||||
return true
|
||||
}
|
||||
lowerText := strings.ToLower(text)
|
||||
start := 0
|
||||
for _, word := range m.words {
|
||||
if start > len(lowerText) {
|
||||
return false
|
||||
}
|
||||
idx := strings.Index(lowerText[start:], word)
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
start += idx + len(word)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewTextMatcher(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedWords []string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expectedWords: []string{},
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " ",
|
||||
expectedWords: []string{},
|
||||
},
|
||||
{
|
||||
name: "single word",
|
||||
input: "alerts",
|
||||
expectedWords: []string{"alerts"},
|
||||
},
|
||||
{
|
||||
name: "multiple words",
|
||||
input: "parent alerts",
|
||||
expectedWords: []string{"parent", "alerts"},
|
||||
},
|
||||
{
|
||||
name: "mixed case normalized to lowercase",
|
||||
input: "Parent Alerts",
|
||||
expectedWords: []string{"parent", "alerts"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matcher := NewTextMatcher(tt.input)
|
||||
assert.Equal(t, tt.expectedWords, matcher.words)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextMatcher_Match(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
search string
|
||||
text string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty search matches everything",
|
||||
search: "",
|
||||
text: "any text",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace search matches everything",
|
||||
search: " ",
|
||||
text: "any text",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
search: "alerts",
|
||||
text: "alerts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
search: "parent alerts",
|
||||
text: "Parent Folder/Alerts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "partial match",
|
||||
search: "folder",
|
||||
text: "Parent Folder/Alerts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sequential words match",
|
||||
search: "api time",
|
||||
text: "API Response Time",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-consecutive but sequential words match",
|
||||
search: "parent rules",
|
||||
text: "Parent Folder/Alert Rules",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "word not found",
|
||||
search: "missing",
|
||||
text: "Parent Folder/Alerts",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "words out of order",
|
||||
search: "alerts parent",
|
||||
text: "Parent Folder/Alerts",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "folder path search",
|
||||
search: "prod alerts",
|
||||
text: "Monitoring/Production/Alerts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple spaces in search",
|
||||
search: "api response time",
|
||||
text: "API Response Time",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matcher := NewTextMatcher(tt.search)
|
||||
result := matcher.Match(tt.text)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user