Alerting: Add rule_matcher filter to Prometheus rules API (#115297)
**What is this feature?** Add `rule_matcher` filter to the Prometheus-compatible list rules API: `/api/prometheus/grafana/api/v1/rules`. It allows to filter rules by static labels (not by alert instance labels). **Special notes:** - Equality (`=`) and inequality (`!=`) matchers are pushed down to the database. Regex matchers (`=~`, `!~`) are applied in-memory at the API layer. - SQLite: Uses GLOB pattern matching - MySQL / PostgreSQL: Use JSON functions to compare label values --------- Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
co-authored by
Konrad Lalik
parent
c7c1dd4ead
commit
c0295d06a3
@@ -2886,6 +2886,189 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with rule_matcher filter", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "alerting", "severity": "critical"}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "Alerting", "severity": "warning"}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "platform", "severity": "critical"}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"env": "production"}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule_special"), gen.WithLabels(map[string]string{"key": `value"with"quotes`}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule_empty"), gen.WithLabels(map[string]string{"empty": ""}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule_nonempty"), gen.WithLabels(map[string]string{"empty": "nonempty"}), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule_multiline"), gen.WithLabels(map[string]string{"description": "line1\nline2\\end\"quote"}), gen.WithNoNotificationSettings())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
matchers []string
|
||||
expectedUIDs []string
|
||||
}{
|
||||
{
|
||||
name: "equality matcher filters by team=alerting",
|
||||
matchers: []string{`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule1"},
|
||||
},
|
||||
{
|
||||
name: "inequality matcher filters severity!=warning",
|
||||
matchers: []string{`{"name":"severity","value":"warning","isRegex":false,"isEqual":false}`},
|
||||
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
|
||||
},
|
||||
{
|
||||
name: "regex matcher filters team=~plat.*",
|
||||
matchers: []string{`{"name":"team","value":"plat.*","isRegex":true,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule3"},
|
||||
},
|
||||
{
|
||||
name: "not-regex matcher filters severity!~warn.*",
|
||||
matchers: []string{`{"name":"severity","value":"warn.*","isRegex":true,"isEqual":false}`},
|
||||
expectedUIDs: []string{"rule1", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
|
||||
},
|
||||
{
|
||||
name: "multiple matchers are ANDed",
|
||||
matchers: []string{
|
||||
`{"name":"team","value":"alerting","isRegex":false,"isEqual":true}`,
|
||||
`{"name":"severity","value":"critical","isRegex":false,"isEqual":true}`,
|
||||
},
|
||||
expectedUIDs: []string{"rule1"},
|
||||
},
|
||||
{
|
||||
name: "matcher with non-existent label returns no rules",
|
||||
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{},
|
||||
},
|
||||
{
|
||||
name: "equality matcher is case-sensitive",
|
||||
matchers: []string{`{"name":"team","value":"Alerting","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule2"},
|
||||
},
|
||||
{
|
||||
name: "quotes in label value are handled correctly",
|
||||
matchers: []string{`{"name":"key","value":"value\"with\"quotes","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule_special"},
|
||||
},
|
||||
{
|
||||
name: "no matchers returns all rules",
|
||||
matchers: []string{},
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
|
||||
},
|
||||
{
|
||||
name: "empty string value matches correctly",
|
||||
matchers: []string{`{"name":"empty","value":"","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_multiline"},
|
||||
},
|
||||
{
|
||||
name: "special characters in label value are handled correctly",
|
||||
matchers: []string{`{"name":"description","value":"line1\nline2\\end\"quote","isRegex":false,"isEqual":true}`},
|
||||
expectedUIDs: []string{"rule_multiline"},
|
||||
},
|
||||
{
|
||||
name: "inequality matcher on non-existent label matches all rules",
|
||||
matchers: []string{`{"name":"nonexistent","value":"value","isRegex":false,"isEqual":false}`},
|
||||
expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4", "rule_special", "rule_empty", "rule_nonempty", "rule_multiline"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reqURL := "/api/v1/rules"
|
||||
for i, matcher := range tc.matchers {
|
||||
if i == 0 {
|
||||
reqURL += "?rule_matcher=" + url.QueryEscape(matcher)
|
||||
} else {
|
||||
reqURL += "&rule_matcher=" + url.QueryEscape(matcher)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
actualUIDs := []string{}
|
||||
for _, group := range res.Data.RuleGroups {
|
||||
for _, rule := range group.Rules {
|
||||
actualUIDs = append(actualUIDs, rule.UID)
|
||||
}
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, tc.expectedUIDs, actualUIDs)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pagination with rule_matcher in-memory filtering", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 3 groups with 2 rules each:
|
||||
// Group 1 & 2: team=backend (won't match filter)
|
||||
// Group 3: team=frontend (will match filter)
|
||||
// This tests that pagination continues fetching when early pages are filtered out
|
||||
|
||||
group1Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace1", RuleGroup: "group1"}
|
||||
group2Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace2", RuleGroup: "group2"}
|
||||
group3Key := ngmodels.AlertRuleGroupKey{OrgID: orgID, NamespaceUID: "namespace3", RuleGroup: "group3"}
|
||||
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule1"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule2"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group1Key), gen.WithNoNotificationSettings())
|
||||
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule3"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule4"), gen.WithLabels(map[string]string{"team": "security"}), gen.WithGroupKey(group2Key), gen.WithNoNotificationSettings())
|
||||
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule5"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
|
||||
generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(),
|
||||
gen.WithUID("rule6"), gen.WithLabels(map[string]string{"team": "alerting"}), gen.WithGroupKey(group3Key), gen.WithNoNotificationSettings())
|
||||
|
||||
// Request with regex rule_matcher filter for team=~"alerting" and group_limit=1 to force pagination
|
||||
matcher := `{"name":"team","value":"alerting","isRegex":true,"isEqual":true}`
|
||||
reqURL := "/api/v1/rules?rule_matcher=" + url.QueryEscape(matcher) + "&group_limit=1"
|
||||
|
||||
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)
|
||||
|
||||
actualUIDs := []string{}
|
||||
for _, group := range res.Data.RuleGroups {
|
||||
for _, rule := range group.Rules {
|
||||
actualUIDs = append(actualUIDs, rule.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// Should return group3 rules (rule5, rule6), pagination should continue past filtered groups
|
||||
require.ElementsMatch(t, []string{"rule5", "rule6"}, actualUIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
|
||||
|
||||
Reference in New Issue
Block a user