Alerting: Fix rule storage to filter by group names using case-sensitive comparison (#88992)

* add test for the bug
* remove unused struct
* update db store to post process filters by group using go-lang's case-sensitive string comparison

--------

Co-authored-by: Alexander Weaver <weaver.alex.d@gmail.com>
This commit is contained in:
Yuri Tseretyan 2024-06-10 19:05:47 -04:00 committed by GitHub
parent ee8a549fdd
commit d4b0ac5973
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 211 additions and 13 deletions

View File

@ -692,18 +692,6 @@ type ListNamespaceAlertRulesQuery struct {
NamespaceUID string
}
// ListOrgRuleGroupsQuery is the query for listing unique rule groups
// for an organization
type ListOrgRuleGroupsQuery struct {
OrgID int64
NamespaceUIDs []string
// DashboardUID and PanelID are optional and allow filtering rules
// to return just those for a dashboard and panel.
DashboardUID string
PanelID int64
}
type UpdateRule struct {
Existing *AlertRule
New AlertRule

View File

@ -114,7 +114,23 @@ func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmode
if err != nil {
return err
}
result = rules
// MySQL by default compares strings without case-sensitivity, make sure we keep the case-sensitive comparison.
var groupKey ngmodels.AlertRuleGroupKey
// find the rule, which group we fetch
for _, rule := range rules {
if rule.UID == query.UID {
groupKey = rule.GetGroupKey()
break
}
}
result = make([]*ngmodels.AlertRule, 0, len(rules))
// MySQL (and potentially other databases) can use case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter.
for _, rule := range rules {
if rule.GetGroupKey() == groupKey {
result = append(result, rule)
}
}
return nil
})
return result, err
@ -372,9 +388,14 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
}
var groupsMap map[string]struct{}
if len(query.RuleGroups) > 0 {
groupsMap = make(map[string]struct{})
args, in := getINSubQueryArgs(query.RuleGroups)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
for _, group := range query.RuleGroups {
groupsMap[group] = struct{}{}
}
}
if query.ReceiverName != "" {
@ -411,6 +432,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
continue
}
}
// MySQL (and potentially other databases) can use case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter.
if groupsMap != nil {
if _, ok := groupsMap[rule.RuleGroup]; !ok {
continue
}
}
alertRules = append(alertRules, rule)
}
@ -526,8 +554,13 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
alertRulesSql.NotIn("org_id", disabledOrgs)
}
var groupsMap map[string]struct{}
if len(query.RuleGroups) > 0 {
alertRulesSql.In("rule_group", query.RuleGroups)
groupsMap = make(map[string]struct{}, len(query.RuleGroups))
for _, group := range query.RuleGroups {
groupsMap[group] = struct{}{}
}
}
rule := new(ngmodels.AlertRule)
@ -548,6 +581,13 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "GetAlertRulesForScheduling", "error", err)
continue
}
// MySQL (and potentially other databases) uses case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter
if groupsMap != nil {
if _, ok := groupsMap[rule.RuleGroup]; !ok { // compare groups using case-sensitive logic.
continue
}
}
if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingQueryOptimization) {
if optimizations, err := OptimizeAlertQueries(rule.Data); err != nil {
st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err)

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
@ -422,6 +423,11 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
ruleGroups: []string{rule1.RuleGroup},
rules: []string{rule1.Title},
},
{
name: "with a rule group filter, should be case sensitive",
ruleGroups: []string{strings.ToUpper(rule1.RuleGroup)},
rules: []string{},
},
{
name: "with a filter on orgs, it returns rules that do not belong to that org",
rules: []string{rule1.Title},
@ -958,6 +964,114 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) {
}
}
func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
store := &DBstore{
SQLStore: sqlStore,
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: log.New("test-dbstore"),
Cfg: cfg.UnifiedAlerting,
FeatureToggles: featuremgmt.WithFeatures(),
}
gen := models.RuleGen.With(models.RuleMuts.WithOrgID(1))
misc := gen.GenerateMany(5, 10)
groupKey1 := models.GenerateGroupKey(1)
groupKey1.RuleGroup = strings.ToLower(groupKey1.RuleGroup)
groupKey2 := groupKey1
groupKey2.RuleGroup = strings.ToUpper(groupKey2.RuleGroup)
groupKey3 := groupKey1
groupKey3.OrgID = 2
group1 := gen.With(gen.WithGroupKey(groupKey1)).GenerateMany(3)
group2 := gen.With(gen.WithGroupKey(groupKey2)).GenerateMany(1, 3)
group3 := gen.With(gen.WithGroupKey(groupKey3)).GenerateMany(1, 3)
_, err := store.InsertAlertRules(context.Background(), append(append(append(misc, group1...), group2...), group3...))
require.NoError(t, err)
t.Run("GetAlertRulesGroupByRuleUID", func(t *testing.T) {
t.Run("should return rules that belong to only that group", func(t *testing.T) {
result, err := store.GetAlertRulesGroupByRuleUID(context.Background(), &models.GetAlertRulesGroupByRuleUIDQuery{
UID: group1[rand.Intn(len(group1))].UID,
OrgID: groupKey1.OrgID,
})
require.NoError(t, err)
assert.Len(t, result, len(group1))
for _, rule := range result {
assert.Equal(t, groupKey1, rule.GetGroupKey())
assert.Truef(t, slices.ContainsFunc(group1, func(r models.AlertRule) bool {
return r.UID == rule.UID
}), "rule with group key [%v] should not be in group [%v]", rule.GetGroupKey(), group1)
}
if t.Failed() {
deref := make([]models.AlertRule, 0, len(result))
for _, rule := range result {
deref = append(deref, *rule)
}
t.Logf("expected rules in group %v: %v\ngot:%v", groupKey1, group1, deref)
}
})
})
t.Run("ListAlertRules", func(t *testing.T) {
t.Run("should find only group with exact case", func(t *testing.T) {
result, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
RuleGroups: []string{groupKey1.RuleGroup},
})
require.NoError(t, err)
assert.Len(t, result, len(group1))
for _, rule := range result {
assert.Equal(t, groupKey1, rule.GetGroupKey())
assert.Truef(t, slices.ContainsFunc(group1, func(r models.AlertRule) bool {
return r.UID == rule.UID
}), "rule with group key [%v] should not be in group [%v]", rule.GetGroupKey(), group1)
}
if t.Failed() {
deref := make([]models.AlertRule, 0, len(result))
for _, rule := range result {
deref = append(deref, *rule)
}
t.Logf("expected rules in group %v: %v\ngot:%v", groupKey1, group1, deref)
}
})
})
t.Run("GetAlertRulesForScheduling", func(t *testing.T) {
t.Run("should find only group with exact case", func(t *testing.T) {
q := &models.GetAlertRulesForSchedulingQuery{
PopulateFolders: false,
RuleGroups: []string{groupKey1.RuleGroup},
}
err := store.GetAlertRulesForScheduling(context.Background(), q)
require.NoError(t, err)
result := q.ResultRules
expected := append(group1, group3...)
assert.Len(t, result, len(expected)) // query fetches all orgs
for _, rule := range result {
assert.Equal(t, groupKey1.RuleGroup, rule.RuleGroup)
assert.Truef(t, slices.ContainsFunc(expected, func(r models.AlertRule) bool {
return r.UID == rule.UID
}), "rule with group key [%v] should not be in group [%v]", rule.GetGroupKey(), group1)
}
if t.Failed() {
deref := make([]models.AlertRule, 0, len(result))
for _, rule := range result {
deref = append(deref, *rule)
}
t.Logf("expected rules in group %v: %v\ngot:%v", groupKey1.RuleGroup, expected, deref)
}
})
})
}
// createAlertRule creates an alert rule in the database and returns it.
// If a generator is not specified, uniqueness of primary key is not guaranteed.
func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule {

View File

@ -2100,3 +2100,59 @@ func TestIntegrationRuleNotificationSettings(t *testing.T) {
}
})
}
func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
// Setup Grafana and its Database
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
// Create a user to make authenticated requests
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
})
client := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
folderUID := util.GenerateShortUID()
client.CreateFolder(t, folderUID, "folder1")
t.Run("group renamed followed by delete for case-only changes should not delete both groups", func(t *testing.T) { // Regression test.
group := generateAlertRuleGroup(3, alertRuleGen())
groupName := group.Name
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
require.Equal(t, groupName, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
newGroup := strings.ToUpper(group.Name)
group.Name = newGroup
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
require.Equal(t, newGroup, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
status, body = client.DeleteRulesGroup(t, folderUID, groupName)
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
// Old group is gone.
getGroup = client.GetRulesGroup(t, folderUID, groupName)
require.Lenf(t, getGroup.Rules, 0, "expected no rules")
// New group still exists.
getGroup = client.GetRulesGroup(t, folderUID, newGroup)
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
require.Equal(t, newGroup, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
})
}