Alerting: Add search.folder filter to the Prometheus rules API (#114358)

This commit is contained in:
Alexander Akhmetov
2025-11-25 12:19:31 +01:00
committed by GitHub
parent f7a19be381
commit 1d4067216d
4 changed files with 286 additions and 0 deletions
@@ -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)
})
}
}