Alerting: Extract alert rule diff logic into separate file with exported API (#53083)

* Refactor diff logic into separate file with exported API

* Fix linter complaint
pull/53085/head
Alexander Weaver 3 years ago committed by GitHub
parent d5c80a2411
commit c50cbea0bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 165
      pkg/services/ngalert/api/api_ruler.go
  2. 416
      pkg/services/ngalert/api/api_ruler_test.go
  3. 3
      pkg/services/ngalert/api/authorization.go
  4. 55
      pkg/services/ngalert/api/authorization_test.go
  5. 165
      pkg/services/ngalert/store/deltas.go
  6. 456
      pkg/services/ngalert/store/deltas_test.go

@ -15,7 +15,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/cmputil"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
@ -325,16 +324,16 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf
// updateAlertRulesInGroup calculates changes (rules to add,update,delete), verifies that the user is authorized to do the calculated changes and updates database. // updateAlertRulesInGroup calculates changes (rules to add,update,delete), verifies that the user is authorized to do the calculated changes and updates database.
// All operations are performed in a single transaction // All operations are performed in a single transaction
func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRule) response.Response { func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRule) response.Response {
var finalChanges *changes var finalChanges *store.GroupDelta
hasAccess := accesscontrol.HasAccess(srv.ac, c) hasAccess := accesscontrol.HasAccess(srv.ac, c)
err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error {
logger := srv.log.New("namespace_uid", groupKey.NamespaceUID, "group", groupKey.RuleGroup, "org_id", groupKey.OrgID, "user_id", c.UserId) logger := srv.log.New("namespace_uid", groupKey.NamespaceUID, "group", groupKey.RuleGroup, "org_id", groupKey.OrgID, "user_id", c.UserId)
groupChanges, err := calculateChanges(tranCtx, srv.store, groupKey, rules) groupChanges, err := store.CalculateChanges(tranCtx, srv.store, groupKey, rules)
if err != nil { if err != nil {
return err return err
} }
if groupChanges.isEmpty() { if groupChanges.IsEmpty() {
finalChanges = groupChanges finalChanges = groupChanges
logger.Info("no changes detected in the request. Do nothing") logger.Info("no changes detected in the request. Do nothing")
return nil return nil
@ -354,7 +353,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod
return err return err
} }
finalChanges = calculateAutomaticChanges(groupChanges) finalChanges = store.UpdateCalculatedRuleFields(groupChanges)
logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete)) logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete))
if len(finalChanges.Update) > 0 || len(finalChanges.New) > 0 { if len(finalChanges.Update) > 0 || len(finalChanges.New) > 0 {
@ -435,7 +434,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod
}) })
} }
if finalChanges.isEmpty() { if finalChanges.IsEmpty() {
return response.JSON(http.StatusAccepted, util.DynMap{"message": "no changes detected in the rule group"}) return response.JSON(http.StatusAccepted, util.DynMap{"message": "no changes detected in the rule group"})
} }
@ -502,29 +501,9 @@ func toNamespaceErrorResponse(err error) response.Response {
return apierrors.ToFolderErrorResponse(err) return apierrors.ToFolderErrorResponse(err)
} }
type ruleUpdate struct {
Existing *ngmodels.AlertRule
New *ngmodels.AlertRule
Diff cmputil.DiffReport
}
type changes struct {
GroupKey ngmodels.AlertRuleGroupKey
// AffectedGroups contains all rules of all groups that are affected by these changes.
// For example, during moving a rule from one group to another this map will contain all rules from two groups
AffectedGroups map[ngmodels.AlertRuleGroupKey]ngmodels.RulesGroup
New []*ngmodels.AlertRule
Update []ruleUpdate
Delete []*ngmodels.AlertRule
}
func (c *changes) isEmpty() bool {
return len(c.Update)+len(c.New)+len(c.Delete) == 0
}
// verifyProvisionedRulesNotAffected check that neither of provisioned alerts are affected by changes. // verifyProvisionedRulesNotAffected check that neither of provisioned alerts are affected by changes.
// Returns errProvisionedResource if there is at least one rule in groups affected by changes that was provisioned. // Returns errProvisionedResource if there is at least one rule in groups affected by changes that was provisioned.
func verifyProvisionedRulesNotAffected(ctx context.Context, provenanceStore provisioning.ProvisioningStore, orgID int64, ch *changes) error { func verifyProvisionedRulesNotAffected(ctx context.Context, provenanceStore provisioning.ProvisioningStore, orgID int64, ch *store.GroupDelta) error {
provenances, err := provenanceStore.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType()) provenances, err := provenanceStore.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType())
if err != nil { if err != nil {
return err return err
@ -547,135 +526,3 @@ func verifyProvisionedRulesNotAffected(ctx context.Context, provenanceStore prov
} }
return fmt.Errorf("%w: alert rule group [%s]", errProvisionedResource, errorMsg.String()) return fmt.Errorf("%w: alert rule group [%s]", errProvisionedResource, errorMsg.String())
} }
// calculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups).
// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules.
func calculateChanges(ctx context.Context, ruleStore store.RuleStore, groupKey ngmodels.AlertRuleGroupKey, submittedRules []*ngmodels.AlertRule) (*changes, error) {
affectedGroups := make(map[ngmodels.AlertRuleGroupKey]ngmodels.RulesGroup)
q := &ngmodels.ListAlertRulesQuery{
OrgID: groupKey.OrgID,
NamespaceUIDs: []string{groupKey.NamespaceUID},
RuleGroup: groupKey.RuleGroup,
}
if err := ruleStore.ListAlertRules(ctx, q); err != nil {
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", groupKey, err)
}
existingGroupRules := q.Result
if len(existingGroupRules) > 0 {
affectedGroups[groupKey] = existingGroupRules
}
existingGroupRulesUIDs := make(map[string]*ngmodels.AlertRule, len(existingGroupRules))
for _, r := range existingGroupRules {
existingGroupRulesUIDs[r.UID] = r
}
var toAdd, toDelete []*ngmodels.AlertRule
var toUpdate []ruleUpdate
loadedRulesByUID := map[string]*ngmodels.AlertRule{} // auxiliary cache to avoid unnecessary queries if there are multiple moves from the same group
for _, r := range submittedRules {
var existing *ngmodels.AlertRule = nil
if r.UID != "" {
if existingGroupRule, ok := existingGroupRulesUIDs[r.UID]; ok {
existing = existingGroupRule
// remove the rule from existingGroupRulesUIDs
delete(existingGroupRulesUIDs, r.UID)
} else if existing, ok = loadedRulesByUID[r.UID]; !ok { // check the "cache" and if there is no hit, query the database
// Rule can be from other group or namespace
q := &ngmodels.GetAlertRulesGroupByRuleUIDQuery{OrgID: groupKey.OrgID, UID: r.UID}
if err := ruleStore.GetAlertRulesGroupByRuleUID(ctx, q); err != nil {
return nil, fmt.Errorf("failed to query database for a group of alert rules: %w", err)
}
for _, rule := range q.Result {
if rule.UID == r.UID {
existing = rule
}
loadedRulesByUID[rule.UID] = rule
}
if existing == nil {
return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, ngmodels.ErrAlertRuleNotFound)
}
affectedGroups[existing.GetGroupKey()] = q.Result
}
}
if existing == nil {
toAdd = append(toAdd, r)
continue
}
ngmodels.PatchPartialAlertRule(existing, r)
diff := existing.Diff(r, alertRuleFieldsToIgnoreInDiff...)
if len(diff) == 0 {
continue
}
toUpdate = append(toUpdate, ruleUpdate{
Existing: existing,
New: r,
Diff: diff,
})
continue
}
for _, rule := range existingGroupRulesUIDs {
toDelete = append(toDelete, rule)
}
return &changes{
GroupKey: groupKey,
AffectedGroups: affectedGroups,
New: toAdd,
Delete: toDelete,
Update: toUpdate,
}, nil
}
// calculateAutomaticChanges scans all affected groups and creates either a noop update that will increment the version of each rule as well as re-index other groups.
// this is needed to make sure that there are no any concurrent changes made to all affected groups.
// Returns a copy of changes enriched with either noop or group index changes for all rules in
func calculateAutomaticChanges(ch *changes) *changes {
updatingRules := make(map[ngmodels.AlertRuleKey]struct{}, len(ch.Delete)+len(ch.Update))
for _, update := range ch.Update {
updatingRules[update.Existing.GetKey()] = struct{}{}
}
for _, del := range ch.Delete {
updatingRules[del.GetKey()] = struct{}{}
}
var toUpdate []ruleUpdate
for groupKey, rules := range ch.AffectedGroups {
if groupKey != ch.GroupKey {
rules.SortByGroupIndex()
}
idx := 1
for _, rule := range rules {
if _, ok := updatingRules[rule.GetKey()]; ok { // exclude rules that are going to be either updated or deleted
continue
}
upd := ruleUpdate{
Existing: rule,
New: rule,
}
if groupKey != ch.GroupKey {
if rule.RuleGroupIndex != idx {
upd.New = ngmodels.CopyRule(rule)
upd.New.RuleGroupIndex = idx
upd.Diff = rule.Diff(upd.New, alertRuleFieldsToIgnoreInDiff...)
}
idx++
}
toUpdate = append(toUpdate, upd)
}
}
return &changes{
GroupKey: ch.GroupKey,
AffectedGroups: ch.AffectedGroups,
New: ch.New,
Update: append(ch.Update, toUpdate...),
Delete: ch.Delete,
}
}
// alertRuleFieldsToIgnoreInDiff contains fields that the AlertRule.Diff should ignore
var alertRuleFieldsToIgnoreInDiff = []string{"ID", "Version", "Updated"}

@ -3,13 +3,11 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@ -29,258 +27,6 @@ import (
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
) )
func TestCalculateChanges(t *testing.T) {
orgId := rand.Int63()
t.Run("detects alerts that need to be added", func(t *testing.T) {
fakeStore := store.NewFakeRuleStore(t)
groupKey := models.GenerateGroupKey(orgId)
submitted := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID))
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Len(t, changes.New, len(submitted))
require.Empty(t, changes.Delete)
require.Empty(t, changes.Update)
outerloop:
for _, expected := range submitted {
for _, rule := range changes.New {
if len(expected.Diff(rule)) == 0 {
continue outerloop
}
}
require.Fail(t, "changes did not contain rule that was submitted")
}
})
t.Run("detects alerts that need to be deleted", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, make([]*models.AlertRule, 0))
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.New)
require.Empty(t, changes.Update)
require.Len(t, changes.Delete, len(inDatabaseMap))
for _, toDelete := range changes.Delete {
require.Contains(t, inDatabaseMap, toDelete.UID)
db := inDatabaseMap[toDelete.UID]
require.Equal(t, db, toDelete)
}
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey])
})
t.Run("should detect alerts that needs to be updated", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
submittedMap, submitted := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap)))
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Len(t, changes.Update, len(inDatabase))
for _, upsert := range changes.Update {
require.NotNil(t, upsert.Existing)
require.Equal(t, upsert.Existing.UID, upsert.New.UID)
require.Equal(t, inDatabaseMap[upsert.Existing.UID], upsert.Existing)
require.Equal(t, submittedMap[upsert.Existing.UID], upsert.New)
require.NotEmpty(t, upsert.Diff)
}
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey])
})
t.Run("should include only if there are changes ignoring specific fields", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
_, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
submitted := make([]*models.AlertRule, 0, len(inDatabase))
for _, rule := range inDatabase {
r := models.CopyRule(rule)
// Ignore difference in the following fields as submitted models do not have them set
r.ID = rand.Int63()
r.Version = rand.Int63()
r.Updated = r.Updated.Add(1 * time.Minute)
submitted = append(submitted, r)
}
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Empty(t, changes.Update)
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
})
t.Run("should patch rule with UID specified by existing rule", func(t *testing.T) {
testCases := []struct {
name string
mutator func(r *models.AlertRule)
}{
{
name: "title is empty",
mutator: func(r *models.AlertRule) {
r.Title = ""
},
},
{
name: "condition and data are empty",
mutator: func(r *models.AlertRule) {
r.Condition = ""
r.Data = nil
},
},
{
name: "ExecErrState is empty",
mutator: func(r *models.AlertRule) {
r.ExecErrState = ""
},
},
{
name: "NoDataState is empty",
mutator: func(r *models.AlertRule) {
r.NoDataState = ""
},
},
{
name: "For is 0",
mutator: func(r *models.AlertRule) {
r.For = 0
},
},
}
dbRule := models.AlertRuleGen(withOrgID(orgId))()
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), dbRule)
groupKey := models.GenerateGroupKey(orgId)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
expected := models.AlertRuleGen(simulateSubmitted, testCase.mutator)()
expected.UID = dbRule.UID
submitted := *expected
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{&submitted})
require.NoError(t, err)
require.Len(t, changes.Update, 1)
ch := changes.Update[0]
require.Equal(t, ch.Existing, dbRule)
fixed := *expected
models.PatchPartialAlertRule(dbRule, &fixed)
require.Equal(t, fixed, *ch.New)
})
}
})
t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) {
sourceGroupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withGroupKey(sourceGroupKey)))
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
namespace := randFolder()
groupName := util.GenerateShortUID()
groupKey := models.AlertRuleGroupKey{
OrgID: orgId,
NamespaceUID: namespace.Uid,
RuleGroup: groupName,
}
submittedMap, submitted := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap)))
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Len(t, changes.Update, len(submitted))
for _, update := range changes.Update {
require.NotNil(t, update.Existing)
require.Equal(t, update.Existing.UID, update.New.UID)
require.Equal(t, inDatabaseMap[update.Existing.UID], update.Existing)
require.Equal(t, submittedMap[update.Existing.UID], update.New)
require.NotEmpty(t, update.Diff)
}
require.Contains(t, changes.AffectedGroups, sourceGroupKey)
require.NotContains(t, changes.AffectedGroups, groupKey) // because there is no such group in database yet
require.Len(t, changes.AffectedGroups[sourceGroupKey], len(inDatabase))
})
t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) {
fakeStore := store.NewFakeRuleStore(t)
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
require.NotEqual(t, "", submitted.UID)
_, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.Error(t, err)
})
t.Run("should fail if cannot fetch current rules in the group", func(t *testing.T) {
fakeStore := store.NewFakeRuleStore(t)
expectedErr := errors.New("TEST ERROR")
fakeStore.Hook = func(cmd interface{}) error {
switch cmd.(type) {
case models.ListAlertRulesQuery:
return expectedErr
}
return nil
}
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)()
_, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.ErrorIs(t, err, expectedErr)
})
t.Run("should fail if cannot fetch rule by UID", func(t *testing.T) {
fakeStore := store.NewFakeRuleStore(t)
expectedErr := errors.New("TEST ERROR")
fakeStore.Hook = func(cmd interface{}) error {
switch cmd.(type) {
case models.GetAlertRulesGroupByRuleUIDQuery:
return expectedErr
}
return nil
}
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
_, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.ErrorIs(t, err, expectedErr)
})
}
func TestRouteDeleteAlertRules(t *testing.T) { func TestRouteDeleteAlertRules(t *testing.T) {
getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery { getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery {
results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) { results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) {
@ -852,7 +598,7 @@ func TestVerifyProvisionedRulesNotAffected(t *testing.T) {
affectedGroups[g] = rules affectedGroups[g] = rules
} }
} }
ch := &changes{ ch := &store.GroupDelta{
GroupKey: group, GroupKey: group,
AffectedGroups: affectedGroups, AffectedGroups: affectedGroups,
} }
@ -897,141 +643,6 @@ func TestVerifyProvisionedRulesNotAffected(t *testing.T) {
}) })
} }
func TestCalculateAutomaticChanges(t *testing.T) {
orgID := rand.Int63()
t.Run("should mark all rules in affected groups", func(t *testing.T) {
group := models.GenerateGroupKey(orgID)
rules := models.GenerateAlertRules(10, models.AlertRuleGen(withGroupKey(group)))
// copy rules to make sure that the function does not modify the original rules
copies := make([]*models.AlertRule, 0, len(rules))
for _, rule := range rules {
copies = append(copies, models.CopyRule(rule))
}
var updates []ruleUpdate
for i := 0; i < 5; i++ {
ruleCopy := models.CopyRule(copies[i])
ruleCopy.Title += util.GenerateShortUID()
updates = append(updates, ruleUpdate{
Existing: copies[i],
New: ruleCopy,
})
}
// simulate adding new rules, updating a few existing and delete some from the same rule
ch := &changes{
GroupKey: group,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
group: copies,
},
New: models.GenerateAlertRules(2, models.AlertRuleGen(withGroupKey(group))),
Update: updates,
Delete: rules[5:7],
}
result := calculateAutomaticChanges(ch)
require.NotEqual(t, ch, result)
require.Equal(t, ch.GroupKey, result.GroupKey)
require.Equal(t, map[models.AlertRuleGroupKey]models.RulesGroup{
group: rules,
}, result.AffectedGroups)
require.Equal(t, ch.New, result.New)
require.Equal(t, rules[5:7], result.Delete)
var expected []ruleUpdate
expected = append(expected, updates...)
// all rules that were not updated directly by user should be added to the
for _, rule := range rules[7:] {
expected = append(expected, ruleUpdate{
Existing: rule,
New: rule,
})
}
require.Equal(t, expected, result.Update)
})
t.Run("should re-index rules in affected groups other than updated", func(t *testing.T) {
group := models.GenerateGroupKey(orgID)
rules := models.GenerateAlertRules(3, models.AlertRuleGen(withGroupKey(group), models.WithSequentialGroupIndex()))
group2 := models.GenerateGroupKey(orgID)
rules2 := models.GenerateAlertRules(4, models.AlertRuleGen(withGroupKey(group2), models.WithSequentialGroupIndex()))
movedIndex := rand.Intn(len(rules2) - 1)
movedRule := rules2[movedIndex]
copyRule := models.CopyRule(movedRule)
copyRule.RuleGroup = group.RuleGroup
copyRule.NamespaceUID = group.NamespaceUID
copyRule.RuleGroupIndex = len(rules)
update := ruleUpdate{
Existing: movedRule,
New: copyRule,
}
shuffled := make([]*models.AlertRule, 0, len(rules2))
copy(shuffled, rules2)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
// simulate moving a rule from one group to another.
ch := &changes{
GroupKey: group,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
group: rules,
group2: shuffled,
},
Update: []ruleUpdate{
update,
},
}
result := calculateAutomaticChanges(ch)
require.NotEqual(t, ch, result)
require.Equal(t, ch.GroupKey, result.GroupKey)
require.Equal(t, ch.AffectedGroups, result.AffectedGroups)
require.Equal(t, ch.New, result.New)
require.Equal(t, ch.Delete, result.Delete)
require.Equal(t, ch.Update, result.Update[0:1])
require.Contains(t, result.Update, update)
for _, rule := range rules {
assert.Containsf(t, result.Update, ruleUpdate{
Existing: rule,
New: rule,
}, "automatic changes expected to contain all rules of the updated group")
}
// calculate expected index of the rules in the source group after the move
expectedReindex := make(map[string]int, len(rules2)-1)
idx := 1
for _, rule := range rules2 {
if rule.UID == movedRule.UID {
continue
}
expectedReindex[rule.UID] = idx
idx++
}
for _, upd := range result.Update {
expectedIdx, ok := expectedReindex[upd.Existing.UID]
if !ok {
continue
}
diff := upd.Existing.Diff(upd.New)
if upd.Existing.RuleGroupIndex != expectedIdx {
require.Lenf(t, diff, 1, fmt.Sprintf("the rule in affected group should be re-indexed to %d but it still has index %d. Moved rule with index %d", expectedIdx, upd.Existing.RuleGroupIndex, movedIndex))
require.Equal(t, "RuleGroupIndex", diff[0].Path)
require.Equal(t, expectedIdx, upd.New.RuleGroupIndex)
} else {
require.Empty(t, diff)
}
}
})
}
func createService(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv { func createService(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv {
return &RulerSrv{ return &RulerSrv{
xactManager: store, xactManager: store,
@ -1102,28 +713,3 @@ func withGroupKey(groupKey models.AlertRuleGroupKey) func(rule *models.AlertRule
rule.NamespaceUID = groupKey.NamespaceUID rule.NamespaceUID = groupKey.NamespaceUID
} }
} }
// simulateSubmitted resets some fields of the structure that are not populated by API model to model conversion
func simulateSubmitted(rule *models.AlertRule) {
rule.ID = 0
rule.Version = 0
rule.Updated = time.Time{}
}
func withoutUID(rule *models.AlertRule) {
rule.UID = ""
}
func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) {
unused := make([]string, 0, len(uids))
for s := range uids {
unused = append(unused, s)
}
return func(rule *models.AlertRule) {
if len(unused) == 0 {
return
}
rule.UID = unused[0]
unused = unused[1:]
}
}

@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
) )
@ -242,7 +243,7 @@ func authorizeAccessToRuleGroup(rules []*ngmodels.AlertRule, evaluator func(eval
// NOTE: if there are rules for deletion, and the user does not have access to data sources that a rule uses, the rule is removed from the list. // NOTE: if there are rules for deletion, and the user does not have access to data sources that a rule uses, the rule is removed from the list.
// If the user is not authorized to perform the changes the function returns ErrAuthorization with a description of what action is not authorized. // If the user is not authorized to perform the changes the function returns ErrAuthorization with a description of what action is not authorized.
// Return changes that the user is authorized to perform or ErrAuthorization // Return changes that the user is authorized to perform or ErrAuthorization
func authorizeRuleChanges(change *changes, evaluator func(evaluator ac.Evaluator) bool) error { func authorizeRuleChanges(change *store.GroupDelta, evaluator func(evaluator ac.Evaluator) bool) error {
namespaceScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(change.GroupKey.NamespaceUID) namespaceScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(change.GroupKey.NamespaceUID)
rules, ok := change.AffectedGroups[change.GroupKey] rules, ok := change.AffectedGroups[change.GroupKey]

@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@ -123,7 +124,7 @@ func getDatasourceScopesForRules(rules models.RulesGroup) []string {
return result return result
} }
func mapUpdates(updates []ruleUpdate, mapFunc func(ruleUpdate) *models.AlertRule) models.RulesGroup { func mapUpdates(updates []store.RuleDelta, mapFunc func(store.RuleDelta) *models.AlertRule) models.RulesGroup {
result := make(models.RulesGroup, 0, len(updates)) result := make(models.RulesGroup, 0, len(updates))
for _, update := range updates { for _, update := range updates {
result = append(result, mapFunc(update)) result = append(result, mapFunc(update))
@ -137,20 +138,20 @@ func TestAuthorizeRuleChanges(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
changes func() *changes changes func() *store.GroupDelta
permissions func(c *changes) map[string][]string permissions func(c *store.GroupDelta) map[string][]string
}{ }{
{ {
name: "if there are rules to add it should check create action and query for datasource", name: "if there are rules to add it should check create action and query for datasource",
changes: func() *changes { changes: func() *store.GroupDelta {
return &changes{ return &store.GroupDelta{
GroupKey: groupKey, GroupKey: groupKey,
New: models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))), New: models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))),
Update: nil, Update: nil,
Delete: nil, Delete: nil,
} }
}, },
permissions: func(c *changes) map[string][]string { permissions: func(c *store.GroupDelta) map[string][]string {
var scopes []string var scopes []string
for _, rule := range c.New { for _, rule := range c.New {
for _, query := range rule.Data { for _, query := range rule.Data {
@ -167,10 +168,10 @@ func TestAuthorizeRuleChanges(t *testing.T) {
}, },
{ {
name: "if there are rules to delete it should check delete action and query for datasource", name: "if there are rules to delete it should check delete action and query for datasource",
changes: func() *changes { changes: func() *store.GroupDelta {
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules2 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules2 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
return &changes{ return &store.GroupDelta{
GroupKey: groupKey, GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: append(rules, rules2...), groupKey: append(rules, rules2...),
@ -180,7 +181,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
Delete: rules2, Delete: rules2,
} }
}, },
permissions: func(c *changes) map[string][]string { permissions: func(c *store.GroupDelta) map[string][]string {
return map[string][]string{ return map[string][]string{
ac.ActionAlertingRuleDelete: { ac.ActionAlertingRuleDelete: {
namespaceIdScope, namespaceIdScope,
@ -191,22 +192,22 @@ func TestAuthorizeRuleChanges(t *testing.T) {
}, },
{ {
name: "if there are rules to update within the same namespace it should check update action and access to datasource", name: "if there are rules to update within the same namespace it should check update action and access to datasource",
changes: func() *changes { changes: func() *store.GroupDelta {
rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
updates := make([]ruleUpdate, 0, len(rules)) updates := make([]store.RuleDelta, 0, len(rules))
for _, rule := range rules { for _, rule := range rules {
cp := models.CopyRule(rule) cp := models.CopyRule(rule)
cp.Data = []models.AlertQuery{models.GenerateAlertQuery()} cp.Data = []models.AlertQuery{models.GenerateAlertQuery()}
updates = append(updates, ruleUpdate{ updates = append(updates, store.RuleDelta{
Existing: rule, Existing: rule,
New: cp, New: cp,
Diff: nil, Diff: nil,
}) })
} }
return &changes{ return &store.GroupDelta{
GroupKey: groupKey, GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: append(rules, rules1...), groupKey: append(rules, rules1...),
@ -216,8 +217,8 @@ func TestAuthorizeRuleChanges(t *testing.T) {
Delete: nil, Delete: nil,
} }
}, },
permissions: func(c *changes) map[string][]string { permissions: func(c *store.GroupDelta) map[string][]string {
scopes := getDatasourceScopesForRules(append(c.AffectedGroups[c.GroupKey], mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule { scopes := getDatasourceScopesForRules(append(c.AffectedGroups[c.GroupKey], mapUpdates(c.Update, func(update store.RuleDelta) *models.AlertRule {
return update.New return update.New
})...)) })...))
return map[string][]string{ return map[string][]string{
@ -230,13 +231,13 @@ func TestAuthorizeRuleChanges(t *testing.T) {
}, },
{ {
name: "if there are rules that are moved between namespaces it should check delete+add action and access to group where rules come from", name: "if there are rules that are moved between namespaces it should check delete+add action and access to group where rules come from",
changes: func() *changes { changes: func() *store.GroupDelta {
rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
targetGroupKey := models.GenerateGroupKey(groupKey.OrgID) targetGroupKey := models.GenerateGroupKey(groupKey.OrgID)
updates := make([]ruleUpdate, 0, len(rules)) updates := make([]store.RuleDelta, 0, len(rules))
for _, rule := range rules { for _, rule := range rules {
cp := models.CopyRule(rule) cp := models.CopyRule(rule)
withGroupKey(targetGroupKey)(cp) withGroupKey(targetGroupKey)(cp)
@ -244,13 +245,13 @@ func TestAuthorizeRuleChanges(t *testing.T) {
models.GenerateAlertQuery(), models.GenerateAlertQuery(),
} }
updates = append(updates, ruleUpdate{ updates = append(updates, store.RuleDelta{
Existing: rule, Existing: rule,
New: cp, New: cp,
}) })
} }
return &changes{ return &store.GroupDelta{
GroupKey: targetGroupKey, GroupKey: targetGroupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: append(rules, rules1...), groupKey: append(rules, rules1...),
@ -260,13 +261,13 @@ func TestAuthorizeRuleChanges(t *testing.T) {
Delete: nil, Delete: nil,
} }
}, },
permissions: func(c *changes) map[string][]string { permissions: func(c *store.GroupDelta) map[string][]string {
dsScopes := getDatasourceScopesForRules( dsScopes := getDatasourceScopesForRules(
append(append(append(c.AffectedGroups[c.GroupKey], append(append(append(c.AffectedGroups[c.GroupKey],
mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule { mapUpdates(c.Update, func(update store.RuleDelta) *models.AlertRule {
return update.New return update.New
})..., })...,
), mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule { ), mapUpdates(c.Update, func(update store.RuleDelta) *models.AlertRule {
return update.Existing return update.Existing
})...), c.AffectedGroups[groupKey]...), })...), c.AffectedGroups[groupKey]...),
) )
@ -287,7 +288,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
}, },
{ {
name: "if there are rules that are moved between groups in the same namespace it should check update action and access to all groups (source+target)", name: "if there are rules that are moved between groups in the same namespace it should check update action and access to all groups (source+target)",
changes: func() *changes { changes: func() *store.GroupDelta {
targetGroupKey := models.AlertRuleGroupKey{ targetGroupKey := models.AlertRuleGroupKey{
OrgID: groupKey.OrgID, OrgID: groupKey.OrgID,
NamespaceUID: groupKey.NamespaceUID, NamespaceUID: groupKey.NamespaceUID,
@ -296,7 +297,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
sourceGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey))) sourceGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
targetGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(targetGroupKey))) targetGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(targetGroupKey)))
updates := make([]ruleUpdate, 0, len(sourceGroup)) updates := make([]store.RuleDelta, 0, len(sourceGroup))
toCopy := len(sourceGroup) toCopy := len(sourceGroup)
if toCopy > 1 { if toCopy > 1 {
toCopy = rand.Intn(toCopy-1) + 1 toCopy = rand.Intn(toCopy-1) + 1
@ -309,13 +310,13 @@ func TestAuthorizeRuleChanges(t *testing.T) {
models.GenerateAlertQuery(), models.GenerateAlertQuery(),
} }
updates = append(updates, ruleUpdate{ updates = append(updates, store.RuleDelta{
Existing: rule, Existing: rule,
New: cp, New: cp,
}) })
} }
return &changes{ return &store.GroupDelta{
GroupKey: targetGroupKey, GroupKey: targetGroupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: sourceGroup, groupKey: sourceGroup,
@ -326,7 +327,7 @@ func TestAuthorizeRuleChanges(t *testing.T) {
Delete: nil, Delete: nil,
} }
}, },
permissions: func(c *changes) map[string][]string { permissions: func(c *store.GroupDelta) map[string][]string {
scopes := make(map[string]struct{}) scopes := make(map[string]struct{})
for _, update := range c.Update { for _, update := range c.Update {
for _, query := range update.New.Data { for _, query := range update.New.Data {

@ -0,0 +1,165 @@
package store
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util/cmputil"
)
// AlertRuleFieldsToIgnoreInDiff contains fields that are ignored when calculating the RuleDelta.Diff.
var AlertRuleFieldsToIgnoreInDiff = [...]string{"ID", "Version", "Updated"}
type RuleDelta struct {
Existing *models.AlertRule
New *models.AlertRule
Diff cmputil.DiffReport
}
type GroupDelta struct {
GroupKey models.AlertRuleGroupKey
// AffectedGroups contains all rules of all groups that are affected by these changes.
// For example, during moving a rule from one group to another this map will contain all rules from two groups
AffectedGroups map[models.AlertRuleGroupKey]models.RulesGroup
New []*models.AlertRule
Update []RuleDelta
Delete []*models.AlertRule
}
func (c *GroupDelta) IsEmpty() bool {
return len(c.Update)+len(c.New)+len(c.Delete) == 0
}
type RuleReader interface {
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) error
GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) error
}
// CalculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups).
// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules.
func CalculateChanges(ctx context.Context, ruleReader RuleReader, groupKey models.AlertRuleGroupKey, submittedRules []*models.AlertRule) (*GroupDelta, error) {
affectedGroups := make(map[models.AlertRuleGroupKey]models.RulesGroup)
q := &models.ListAlertRulesQuery{
OrgID: groupKey.OrgID,
NamespaceUIDs: []string{groupKey.NamespaceUID},
RuleGroup: groupKey.RuleGroup,
}
if err := ruleReader.ListAlertRules(ctx, q); err != nil {
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", groupKey, err)
}
existingGroupRules := q.Result
if len(existingGroupRules) > 0 {
affectedGroups[groupKey] = existingGroupRules
}
existingGroupRulesUIDs := make(map[string]*models.AlertRule, len(existingGroupRules))
for _, r := range existingGroupRules {
existingGroupRulesUIDs[r.UID] = r
}
var toAdd, toDelete []*models.AlertRule
var toUpdate []RuleDelta
loadedRulesByUID := map[string]*models.AlertRule{} // auxiliary cache to avoid unnecessary queries if there are multiple moves from the same group
for _, r := range submittedRules {
var existing *models.AlertRule = nil
if r.UID != "" {
if existingGroupRule, ok := existingGroupRulesUIDs[r.UID]; ok {
existing = existingGroupRule
// remove the rule from existingGroupRulesUIDs
delete(existingGroupRulesUIDs, r.UID)
} else if existing, ok = loadedRulesByUID[r.UID]; !ok { // check the "cache" and if there is no hit, query the database
// Rule can be from other group or namespace
q := &models.GetAlertRulesGroupByRuleUIDQuery{OrgID: groupKey.OrgID, UID: r.UID}
if err := ruleReader.GetAlertRulesGroupByRuleUID(ctx, q); err != nil {
return nil, fmt.Errorf("failed to query database for a group of alert rules: %w", err)
}
for _, rule := range q.Result {
if rule.UID == r.UID {
existing = rule
}
loadedRulesByUID[rule.UID] = rule
}
if existing == nil {
return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, models.ErrAlertRuleNotFound)
}
affectedGroups[existing.GetGroupKey()] = q.Result
}
}
if existing == nil {
toAdd = append(toAdd, r)
continue
}
models.PatchPartialAlertRule(existing, r)
diff := existing.Diff(r, AlertRuleFieldsToIgnoreInDiff[:]...)
if len(diff) == 0 {
continue
}
toUpdate = append(toUpdate, RuleDelta{
Existing: existing,
New: r,
Diff: diff,
})
continue
}
for _, rule := range existingGroupRulesUIDs {
toDelete = append(toDelete, rule)
}
return &GroupDelta{
GroupKey: groupKey,
AffectedGroups: affectedGroups,
New: toAdd,
Delete: toDelete,
Update: toUpdate,
}, nil
}
// UpdateCalculatedRuleFields refreshes the calculated fields in a set of alert rule changes.
// This may generate new changes to keep a group consistent, such as versions or rule indexes.
func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
updatingRules := make(map[models.AlertRuleKey]struct{}, len(ch.Delete)+len(ch.Update))
for _, update := range ch.Update {
updatingRules[update.Existing.GetKey()] = struct{}{}
}
for _, del := range ch.Delete {
updatingRules[del.GetKey()] = struct{}{}
}
var toUpdate []RuleDelta
for groupKey, rules := range ch.AffectedGroups {
if groupKey != ch.GroupKey {
rules.SortByGroupIndex()
}
idx := 1
for _, rule := range rules {
if _, ok := updatingRules[rule.GetKey()]; ok { // exclude rules that are going to be either updated or deleted
continue
}
upd := RuleDelta{
Existing: rule,
New: rule,
}
if groupKey != ch.GroupKey {
if rule.RuleGroupIndex != idx {
upd.New = models.CopyRule(rule)
upd.New.RuleGroupIndex = idx
upd.Diff = rule.Diff(upd.New, AlertRuleFieldsToIgnoreInDiff[:]...)
}
idx++
}
toUpdate = append(toUpdate, upd)
}
}
return &GroupDelta{
GroupKey: ch.GroupKey,
AffectedGroups: ch.AffectedGroups,
New: ch.New,
Update: append(ch.Update, toUpdate...),
Delete: ch.Delete,
}
}

@ -0,0 +1,456 @@
package store
import (
"context"
"errors"
"fmt"
"testing"
"time"
grafana_models "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/rand"
)
func TestCalculateChanges(t *testing.T) {
orgId := rand.Int63()
t.Run("detects alerts that need to be added", func(t *testing.T) {
fakeStore := NewFakeRuleStore(t)
groupKey := models.GenerateGroupKey(orgId)
submitted := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID))
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Len(t, changes.New, len(submitted))
require.Empty(t, changes.Delete)
require.Empty(t, changes.Update)
outerloop:
for _, expected := range submitted {
for _, rule := range changes.New {
if len(expected.Diff(rule)) == 0 {
continue outerloop
}
}
require.Fail(t, "changes did not contain rule that was submitted")
}
})
t.Run("detects alerts that need to be deleted", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
fakeStore := NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, make([]*models.AlertRule, 0))
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.New)
require.Empty(t, changes.Update)
require.Len(t, changes.Delete, len(inDatabaseMap))
for _, toDelete := range changes.Delete {
require.Contains(t, inDatabaseMap, toDelete.UID)
db := inDatabaseMap[toDelete.UID]
require.Equal(t, db, toDelete)
}
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey])
})
t.Run("should detect alerts that needs to be updated", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
submittedMap, submitted := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap)))
fakeStore := NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Len(t, changes.Update, len(inDatabase))
for _, upsert := range changes.Update {
require.NotNil(t, upsert.Existing)
require.Equal(t, upsert.Existing.UID, upsert.New.UID)
require.Equal(t, inDatabaseMap[upsert.Existing.UID], upsert.Existing)
require.Equal(t, submittedMap[upsert.Existing.UID], upsert.New)
require.NotEmpty(t, upsert.Diff)
}
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, models.RulesGroup(inDatabase), changes.AffectedGroups[groupKey])
})
t.Run("should include only if there are changes ignoring specific fields", func(t *testing.T) {
groupKey := models.GenerateGroupKey(orgId)
_, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey)))
submitted := make([]*models.AlertRule, 0, len(inDatabase))
for _, rule := range inDatabase {
r := models.CopyRule(rule)
// Ignore difference in the following fields as submitted models do not have them set
r.ID = rand.Int63()
r.Version = rand.Int63()
r.Updated = r.Updated.Add(1 * time.Minute)
submitted = append(submitted, r)
}
fakeStore := NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Empty(t, changes.Update)
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
})
t.Run("should patch rule with UID specified by existing rule", func(t *testing.T) {
testCases := []struct {
name string
mutator func(r *models.AlertRule)
}{
{
name: "title is empty",
mutator: func(r *models.AlertRule) {
r.Title = ""
},
},
{
name: "condition and data are empty",
mutator: func(r *models.AlertRule) {
r.Condition = ""
r.Data = nil
},
},
{
name: "ExecErrState is empty",
mutator: func(r *models.AlertRule) {
r.ExecErrState = ""
},
},
{
name: "NoDataState is empty",
mutator: func(r *models.AlertRule) {
r.NoDataState = ""
},
},
{
name: "For is 0",
mutator: func(r *models.AlertRule) {
r.For = 0
},
},
}
dbRule := models.AlertRuleGen(withOrgID(orgId))()
fakeStore := NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), dbRule)
groupKey := models.GenerateGroupKey(orgId)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
expected := models.AlertRuleGen(simulateSubmitted, testCase.mutator)()
expected.UID = dbRule.UID
submitted := *expected
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{&submitted})
require.NoError(t, err)
require.Len(t, changes.Update, 1)
ch := changes.Update[0]
require.Equal(t, ch.Existing, dbRule)
fixed := *expected
models.PatchPartialAlertRule(dbRule, &fixed)
require.Equal(t, fixed, *ch.New)
})
}
})
t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) {
sourceGroupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withGroupKey(sourceGroupKey)))
fakeStore := NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
namespace := randFolder()
groupName := util.GenerateShortUID()
groupKey := models.AlertRuleGroupKey{
OrgID: orgId,
NamespaceUID: namespace.Uid,
RuleGroup: groupName,
}
submittedMap, submitted := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap)))
changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Len(t, changes.Update, len(submitted))
for _, update := range changes.Update {
require.NotNil(t, update.Existing)
require.Equal(t, update.Existing.UID, update.New.UID)
require.Equal(t, inDatabaseMap[update.Existing.UID], update.Existing)
require.Equal(t, submittedMap[update.Existing.UID], update.New)
require.NotEmpty(t, update.Diff)
}
require.Contains(t, changes.AffectedGroups, sourceGroupKey)
require.NotContains(t, changes.AffectedGroups, groupKey) // because there is no such group in database yet
require.Len(t, changes.AffectedGroups[sourceGroupKey], len(inDatabase))
})
t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) {
fakeStore := NewFakeRuleStore(t)
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
require.NotEqual(t, "", submitted.UID)
_, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.Error(t, err)
})
t.Run("should fail if cannot fetch current rules in the group", func(t *testing.T) {
fakeStore := NewFakeRuleStore(t)
expectedErr := errors.New("TEST ERROR")
fakeStore.Hook = func(cmd interface{}) error {
switch cmd.(type) {
case models.ListAlertRulesQuery:
return expectedErr
}
return nil
}
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)()
_, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.ErrorIs(t, err, expectedErr)
})
t.Run("should fail if cannot fetch rule by UID", func(t *testing.T) {
fakeStore := NewFakeRuleStore(t)
expectedErr := errors.New("TEST ERROR")
fakeStore.Hook = func(cmd interface{}) error {
switch cmd.(type) {
case models.GetAlertRulesGroupByRuleUIDQuery:
return expectedErr
}
return nil
}
groupKey := models.GenerateGroupKey(orgId)
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
_, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.ErrorIs(t, err, expectedErr)
})
}
func TestCalculateAutomaticChanges(t *testing.T) {
orgID := rand.Int63()
t.Run("should mark all rules in affected groups", func(t *testing.T) {
group := models.GenerateGroupKey(orgID)
rules := models.GenerateAlertRules(10, models.AlertRuleGen(withGroupKey(group)))
// copy rules to make sure that the function does not modify the original rules
copies := make([]*models.AlertRule, 0, len(rules))
for _, rule := range rules {
copies = append(copies, models.CopyRule(rule))
}
var updates []RuleDelta
for i := 0; i < 5; i++ {
ruleCopy := models.CopyRule(copies[i])
ruleCopy.Title += util.GenerateShortUID()
updates = append(updates, RuleDelta{
Existing: copies[i],
New: ruleCopy,
})
}
// simulate adding new rules, updating a few existing and delete some from the same rule
ch := &GroupDelta{
GroupKey: group,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
group: copies,
},
New: models.GenerateAlertRules(2, models.AlertRuleGen(withGroupKey(group))),
Update: updates,
Delete: rules[5:7],
}
result := UpdateCalculatedRuleFields(ch)
require.NotEqual(t, ch, result)
require.Equal(t, ch.GroupKey, result.GroupKey)
require.Equal(t, map[models.AlertRuleGroupKey]models.RulesGroup{
group: rules,
}, result.AffectedGroups)
require.Equal(t, ch.New, result.New)
require.Equal(t, rules[5:7], result.Delete)
var expected []RuleDelta
expected = append(expected, updates...)
// all rules that were not updated directly by user should be added to the
for _, rule := range rules[7:] {
expected = append(expected, RuleDelta{
Existing: rule,
New: rule,
})
}
require.Equal(t, expected, result.Update)
})
t.Run("should re-index rules in affected groups other than updated", func(t *testing.T) {
group := models.GenerateGroupKey(orgID)
rules := models.GenerateAlertRules(3, models.AlertRuleGen(withGroupKey(group), models.WithSequentialGroupIndex()))
group2 := models.GenerateGroupKey(orgID)
rules2 := models.GenerateAlertRules(4, models.AlertRuleGen(withGroupKey(group2), models.WithSequentialGroupIndex()))
movedIndex := rand.Intn(len(rules2) - 1)
movedRule := rules2[movedIndex]
copyRule := models.CopyRule(movedRule)
copyRule.RuleGroup = group.RuleGroup
copyRule.NamespaceUID = group.NamespaceUID
copyRule.RuleGroupIndex = len(rules)
update := RuleDelta{
Existing: movedRule,
New: copyRule,
}
shuffled := make([]*models.AlertRule, 0, len(rules2))
copy(shuffled, rules2)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
// simulate moving a rule from one group to another.
ch := &GroupDelta{
GroupKey: group,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
group: rules,
group2: shuffled,
},
Update: []RuleDelta{
update,
},
}
result := UpdateCalculatedRuleFields(ch)
require.NotEqual(t, ch, result)
require.Equal(t, ch.GroupKey, result.GroupKey)
require.Equal(t, ch.AffectedGroups, result.AffectedGroups)
require.Equal(t, ch.New, result.New)
require.Equal(t, ch.Delete, result.Delete)
require.Equal(t, ch.Update, result.Update[0:1])
require.Contains(t, result.Update, update)
for _, rule := range rules {
assert.Containsf(t, result.Update, RuleDelta{
Existing: rule,
New: rule,
}, "automatic changes expected to contain all rules of the updated group")
}
// calculate expected index of the rules in the source group after the move
expectedReindex := make(map[string]int, len(rules2)-1)
idx := 1
for _, rule := range rules2 {
if rule.UID == movedRule.UID {
continue
}
expectedReindex[rule.UID] = idx
idx++
}
for _, upd := range result.Update {
expectedIdx, ok := expectedReindex[upd.Existing.UID]
if !ok {
continue
}
diff := upd.Existing.Diff(upd.New)
if upd.Existing.RuleGroupIndex != expectedIdx {
require.Lenf(t, diff, 1, fmt.Sprintf("the rule in affected group should be re-indexed to %d but it still has index %d. Moved rule with index %d", expectedIdx, upd.Existing.RuleGroupIndex, movedIndex))
require.Equal(t, "RuleGroupIndex", diff[0].Path)
require.Equal(t, expectedIdx, upd.New.RuleGroupIndex)
} else {
require.Empty(t, diff)
}
}
})
}
// simulateSubmitted resets some fields of the structure that are not populated by API model to model conversion
func simulateSubmitted(rule *models.AlertRule) {
rule.ID = 0
rule.Version = 0
rule.Updated = time.Time{}
}
func withOrgID(orgId int64) func(rule *models.AlertRule) {
return func(rule *models.AlertRule) {
rule.OrgID = orgId
}
}
func withoutUID(rule *models.AlertRule) {
rule.UID = ""
}
func withGroupKey(groupKey models.AlertRuleGroupKey) func(rule *models.AlertRule) {
return func(rule *models.AlertRule) {
rule.RuleGroup = groupKey.RuleGroup
rule.OrgID = groupKey.OrgID
rule.NamespaceUID = groupKey.NamespaceUID
}
}
func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) {
unused := make([]string, 0, len(uids))
for s := range uids {
unused = append(unused, s)
}
return func(rule *models.AlertRule) {
if len(unused) == 0 {
return
}
rule.UID = unused[0]
unused = unused[1:]
}
}
func randFolder() *grafana_models.Folder {
return &grafana_models.Folder{
Id: rand.Int63(),
Uid: util.GenerateShortUID(),
Title: "TEST-FOLDER-" + util.GenerateShortUID(),
Url: "",
Version: 0,
Created: time.Time{},
Updated: time.Time{},
UpdatedBy: 0,
CreatedBy: 0,
}
}
Loading…
Cancel
Save