Alerting: validate rules and calculate changes in API controller (#45072)
* Update API controller - add validation of rules API model - add function to calculate changes between the submitted alerts and existing alerts - update RoutePostNameRulesConfig to validate input models, calculate changes and apply in a transaction * Update DBStore - delete unused storage method. All the logic is moved upstream. - upsert to not modify fields of new by values from the existing alert - if rule has UID do not try to pull it from db. (it is done upstream) * Add rule generator
This commit is contained in:
@@ -95,7 +95,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
api.RegisterRulerApiEndpoints(NewForkedRuler(
|
||||
api.DatasourceCache,
|
||||
NewLotexRuler(proxy, logger),
|
||||
&RulerSrv{DatasourceCache: api.DatasourceCache, QuotaService: api.QuotaService, scheduleService: api.Schedule, store: api.RuleStore, log: logger},
|
||||
&RulerSrv{DatasourceCache: api.DatasourceCache, QuotaService: api.QuotaService, scheduleService: api.Schedule, store: api.RuleStore, log: logger, cfg: &api.Cfg.UnifiedAlerting},
|
||||
), m)
|
||||
api.RegisterTestingApiEndpoints(NewForkedTestingApi(
|
||||
&TestingApiSrv{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -29,8 +31,13 @@ type RulerSrv struct {
|
||||
QuotaService *quota.QuotaService
|
||||
scheduleService schedule.ScheduleService
|
||||
log log.Logger
|
||||
cfg *setting.UnifiedAlertingSettings
|
||||
}
|
||||
|
||||
var (
|
||||
errQuotaReached = errors.New("quota has been exceeded")
|
||||
)
|
||||
|
||||
func (srv RulerSrv) RouteDeleteNamespaceRulesConfig(c *models.ReqContext) response.Response {
|
||||
namespaceTitle := web.Params(c.Req)[":Namespace"]
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgId, c.SignedInUser, true)
|
||||
@@ -243,62 +250,77 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
||||
//TODO: Should this belong in alerting-api?
|
||||
if ruleGroupConfig.Name == "" {
|
||||
return ErrResp(http.StatusBadRequest, errors.New("rule group name is not valid"), "")
|
||||
rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgId, namespace, conditionValidator(c, srv.DatasourceCache), srv.cfg)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
alertRuleUIDs := make(map[string]struct{})
|
||||
for _, r := range ruleGroupConfig.Rules {
|
||||
cond := ngmodels.Condition{
|
||||
Condition: r.GrafanaManagedAlert.Condition,
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
Data: r.GrafanaManagedAlert.Data,
|
||||
}
|
||||
if err := validateCondition(c.Req.Context(), cond, c.SignedInUser, c.SkipCache, srv.DatasourceCache); err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "failed to validate alert rule %q", r.GrafanaManagedAlert.Title)
|
||||
}
|
||||
if r.GrafanaManagedAlert.UID != "" {
|
||||
_, ok := alertRuleUIDs[r.GrafanaManagedAlert.UID]
|
||||
if ok {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("conflicting UID %q found", r.GrafanaManagedAlert.UID), "failed to validate alert rule %q", r.GrafanaManagedAlert.Title)
|
||||
}
|
||||
alertRuleUIDs[r.GrafanaManagedAlert.UID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return srv.updateAlertRulesInGroup(c, namespace, ruleGroupConfig.Name, rules)
|
||||
}
|
||||
|
||||
numOfNewRules := len(ruleGroupConfig.Rules) - len(alertRuleUIDs)
|
||||
if numOfNewRules > 0 {
|
||||
// quotas are checked in advanced
|
||||
// that is acceptable under the assumption that there will be only one alert rule under the rule group
|
||||
// alternatively we should check the quotas after the rule group update
|
||||
// and rollback the transaction in case of violation
|
||||
limitReached, err := srv.QuotaService.QuotaReached(c, "alert_rule")
|
||||
func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *models.Folder, groupName string, rules []*ngmodels.AlertRule) response.Response {
|
||||
// TODO add create rules authz logic
|
||||
|
||||
var changes *RuleChanges = nil
|
||||
err := srv.store.InTransaction(c.Req.Context(), func(tranCtx context.Context) error {
|
||||
var err error
|
||||
changes, err = calculateChanges(tranCtx, srv.store, c.SignedInUser.OrgId, namespace, groupName, rules)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to get quota")
|
||||
return err
|
||||
}
|
||||
if limitReached {
|
||||
return ErrResp(http.StatusForbidden, errors.New("quota reached"), "")
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.store.UpdateRuleGroup(c.Req.Context(), store.UpdateRuleGroupCmd{
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
NamespaceUID: namespace.Uid,
|
||||
RuleGroupConfig: ruleGroupConfig,
|
||||
}); err != nil {
|
||||
// TODO add update/delete authz logic
|
||||
err = srv.store.UpsertAlertRules(tranCtx, changes.Upsert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add or update rules: %w", err)
|
||||
}
|
||||
|
||||
for _, rule := range changes.Delete {
|
||||
if err = srv.store.DeleteAlertRuleByUID(tranCtx, c.SignedInUser.OrgId, rule.UID); err != nil {
|
||||
return fmt.Errorf("failed to delete rule %d with UID %s: %w", rule.ID, rule.UID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if changes.newRules > 0 {
|
||||
limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{
|
||||
OrgId: c.OrgId,
|
||||
UserId: c.UserId,
|
||||
}) // alert rule is table name
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get alert rules quota: %w", err)
|
||||
}
|
||||
if limitReached {
|
||||
return errQuotaReached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return ErrResp(http.StatusNotFound, err, "failed to update rule group")
|
||||
} else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "failed to update rule group")
|
||||
} else if errors.Is(err, errQuotaReached) {
|
||||
return ErrResp(http.StatusForbidden, err, "")
|
||||
}
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to update rule group")
|
||||
}
|
||||
|
||||
for uid := range alertRuleUIDs {
|
||||
srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{
|
||||
// TODO uncomment when rules that are not changed will be filter out from the upsert list.
|
||||
// for _, rule := range changes.Upsert {
|
||||
// if rule.Existing != nil {
|
||||
// srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{
|
||||
// OrgID: c.SignedInUser.OrgId,
|
||||
// UID: rule.Existing.UID,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
for _, rule := range changes.Delete {
|
||||
srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{
|
||||
OrgID: c.SignedInUser.OrgId,
|
||||
UID: uid,
|
||||
UID: rule.UID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -341,3 +363,81 @@ func toNamespaceErrorResponse(err error) response.Response {
|
||||
}
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
|
||||
type RuleChanges struct {
|
||||
newRules int
|
||||
Upsert []store.UpsertRule
|
||||
Delete []*ngmodels.AlertRule
|
||||
}
|
||||
|
||||
// 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, orgId int64, namespace *models.Folder, ruleGroupName string, submittedRules []*ngmodels.AlertRule) (*RuleChanges, error) {
|
||||
q := &ngmodels.ListRuleGroupAlertRulesQuery{
|
||||
OrgID: orgId,
|
||||
NamespaceUID: namespace.Uid,
|
||||
RuleGroup: ruleGroupName,
|
||||
}
|
||||
if err := ruleStore.GetRuleGroupAlertRules(ctx, q); err != nil {
|
||||
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", ruleGroupName, err)
|
||||
}
|
||||
existingGroupRules := q.Result
|
||||
|
||||
existingGroupRulesUIDs := make(map[string]*ngmodels.AlertRule, len(existingGroupRules))
|
||||
for _, r := range existingGroupRules {
|
||||
existingGroupRulesUIDs[r.UID] = r
|
||||
}
|
||||
|
||||
upsert := make([]store.UpsertRule, 0, len(submittedRules))
|
||||
toDelete := make([]*ngmodels.AlertRule, 0, len(submittedRules))
|
||||
newRules := 0
|
||||
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 {
|
||||
// Rule can be from other group or namespace
|
||||
q := &ngmodels.GetAlertRuleByUIDQuery{OrgID: orgId, UID: r.UID}
|
||||
if err := ruleStore.GetAlertRuleByUID(ctx, q); err != nil || q.Result == nil {
|
||||
// if rule has UID then it is considered an update. Therefore, fail if there is no rule to update
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) || q.Result == nil && err == nil {
|
||||
return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, ngmodels.ErrAlertRuleNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query database for an alert rule with UID %s: %w", r.UID, err)
|
||||
}
|
||||
existing = q.Result
|
||||
}
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
upsert = append(upsert, store.UpsertRule{
|
||||
Existing: nil,
|
||||
New: *r,
|
||||
})
|
||||
newRules++
|
||||
continue
|
||||
}
|
||||
|
||||
ngmodels.PatchPartialAlertRule(existing, r)
|
||||
// TODO diff between patched and existing, as well as between submitted
|
||||
upsert = append(upsert, store.UpsertRule{
|
||||
Existing: existing,
|
||||
New: *r,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range existingGroupRulesUIDs {
|
||||
toDelete = append(toDelete, rule)
|
||||
}
|
||||
|
||||
return &RuleChanges{
|
||||
Upsert: upsert,
|
||||
Delete: toDelete,
|
||||
newRules: newRules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
models2 "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
submitted := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID))
|
||||
|
||||
changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, changes.newRules, len(submitted))
|
||||
require.Empty(t, changes.Delete)
|
||||
require.Len(t, changes.Upsert, len(submitted))
|
||||
for _, rule := range changes.Upsert {
|
||||
require.Nil(t, rule.Existing)
|
||||
}
|
||||
|
||||
opts := []cmp.Option{
|
||||
cmp.FilterPath(func(path cmp.Path) bool {
|
||||
return path.String() == "Data.modelProps"
|
||||
}, cmp.Ignore()),
|
||||
}
|
||||
|
||||
outerloop:
|
||||
for _, expected := range submitted {
|
||||
for _, rule := range changes.Upsert {
|
||||
if cmp.Equal(*expected, rule.New, opts...) {
|
||||
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) {
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), withGroup(groupName), withNamespace(namespace)))
|
||||
|
||||
fakeStore := store.NewFakeRuleStore(t)
|
||||
fakeStore.PutRule(context.Background(), inDatabase...)
|
||||
|
||||
changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, make([]*models.AlertRule, 0))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 0, changes.newRules)
|
||||
require.Len(t, changes.Upsert, 0)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should detect alerts that needs to be updated", func(t *testing.T) {
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), withGroup(groupName), withNamespace(namespace)))
|
||||
submittedMap, submitted := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withOrgID(orgId), withGroup(groupName), withNamespace(namespace), withUIDs(inDatabaseMap)))
|
||||
|
||||
fakeStore := store.NewFakeRuleStore(t)
|
||||
fakeStore.PutRule(context.Background(), inDatabase...)
|
||||
|
||||
changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, changes.Upsert, len(inDatabase))
|
||||
for _, upsert := range changes.Upsert {
|
||||
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.Len(t, changes.Delete, 0)
|
||||
require.Equal(t, 0, changes.newRules)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
|
||||
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, orgId, namespace, groupName, []*models.AlertRule{&submitted})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes.Upsert, 1)
|
||||
ch := changes.Upsert[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) {
|
||||
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withOrgID(orgId)))
|
||||
|
||||
fakeStore := store.NewFakeRuleStore(t)
|
||||
fakeStore.PutRule(context.Background(), inDatabase...)
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
submittedMap, submitted := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withOrgID(orgId), withGroup(groupName), withNamespace(namespace), withUIDs(inDatabaseMap)))
|
||||
|
||||
changes, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, submitted)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, changes.Delete, 0)
|
||||
require.Equal(t, 0, changes.newRules)
|
||||
require.Len(t, changes.Upsert, len(submitted))
|
||||
for _, upsert := range changes.Upsert {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) {
|
||||
fakeStore := store.NewFakeRuleStore(t)
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
|
||||
require.NotEqual(t, "", submitted.UID)
|
||||
|
||||
_, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*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.ListRuleGroupAlertRulesQuery:
|
||||
return expectedErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)()
|
||||
|
||||
_, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*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.GetAlertRuleByUIDQuery:
|
||||
return expectedErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
namespace := randFolder()
|
||||
groupName := util.GenerateShortUID()
|
||||
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
|
||||
|
||||
_, err := calculateChanges(context.Background(), fakeStore, orgId, namespace, groupName, []*models.AlertRule{submitted})
|
||||
require.Error(t, err, expectedErr)
|
||||
})
|
||||
}
|
||||
|
||||
func withOrgID(orgId int64) func(rule *models.AlertRule) {
|
||||
return func(rule *models.AlertRule) {
|
||||
rule.OrgID = orgId
|
||||
}
|
||||
}
|
||||
|
||||
func withGroup(groupName string) func(rule *models.AlertRule) {
|
||||
return func(rule *models.AlertRule) {
|
||||
rule.RuleGroup = groupName
|
||||
}
|
||||
}
|
||||
|
||||
func withNamespace(namespace *models2.Folder) func(rule *models.AlertRule) {
|
||||
return func(rule *models.AlertRule) {
|
||||
rule.NamespaceUID = namespace.Uid
|
||||
}
|
||||
}
|
||||
|
||||
// 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:]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// validateRuleNode validates API model (definitions.PostableExtendedRuleNode) and converts it to models.AlertRule
|
||||
func validateRuleNode(
|
||||
ruleNode *apimodels.PostableExtendedRuleNode,
|
||||
groupName string,
|
||||
interval time.Duration,
|
||||
orgId int64,
|
||||
namespace *models.Folder,
|
||||
conditionValidator func(ngmodels.Condition) error,
|
||||
cfg *setting.UnifiedAlertingSettings) (*ngmodels.AlertRule, error) {
|
||||
intervalSeconds := int64(interval.Seconds())
|
||||
|
||||
baseIntervalSeconds := int64(cfg.BaseInterval.Seconds())
|
||||
|
||||
if interval <= 0 {
|
||||
return nil, fmt.Errorf("rule evaluation interval must be positive duration that is multiple of the base interval %d seconds", baseIntervalSeconds)
|
||||
}
|
||||
|
||||
if intervalSeconds%baseIntervalSeconds != 0 {
|
||||
return nil, fmt.Errorf("rule evaluation interval %d should be multiple of the base interval of %d seconds", int64(interval.Seconds()), baseIntervalSeconds)
|
||||
}
|
||||
|
||||
if ruleNode.GrafanaManagedAlert == nil {
|
||||
return nil, fmt.Errorf("not Grafana managed alert rule")
|
||||
}
|
||||
|
||||
// if UID is specified then we can accept partial model. Therefore, some validation can be skipped as it will be patched later
|
||||
canPatch := ruleNode.GrafanaManagedAlert.UID != ""
|
||||
|
||||
if ruleNode.GrafanaManagedAlert.Title == "" && !canPatch {
|
||||
return nil, errors.New("alert rule title cannot be empty")
|
||||
}
|
||||
|
||||
if len(ruleNode.GrafanaManagedAlert.Title) > store.AlertRuleMaxTitleLength {
|
||||
return nil, fmt.Errorf("alert rule title is too long. Max length is %d", store.AlertRuleMaxTitleLength)
|
||||
}
|
||||
|
||||
noDataState := ngmodels.NoData
|
||||
if ruleNode.GrafanaManagedAlert.NoDataState == "" && canPatch {
|
||||
noDataState = ""
|
||||
}
|
||||
|
||||
if ruleNode.GrafanaManagedAlert.NoDataState != "" {
|
||||
var err error
|
||||
noDataState, err = ngmodels.NoDataStateFromString(string(ruleNode.GrafanaManagedAlert.NoDataState))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
errorState := ngmodels.AlertingErrState
|
||||
|
||||
if ruleNode.GrafanaManagedAlert.ExecErrState == "" && canPatch {
|
||||
errorState = ""
|
||||
}
|
||||
|
||||
if ruleNode.GrafanaManagedAlert.ExecErrState != "" {
|
||||
var err error
|
||||
errorState, err = ngmodels.ErrStateFromString(string(ruleNode.GrafanaManagedAlert.ExecErrState))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(ruleNode.GrafanaManagedAlert.Data) == 0 {
|
||||
if canPatch {
|
||||
if ruleNode.GrafanaManagedAlert.Condition != "" {
|
||||
return nil, fmt.Errorf("%w: query is not specified by condition is. You must specify both query and condition to update existing alert rule", ngmodels.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("%w: no queries or expressions are found", ngmodels.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ruleNode.GrafanaManagedAlert.Data) != 0 {
|
||||
cond := ngmodels.Condition{
|
||||
Condition: ruleNode.GrafanaManagedAlert.Condition,
|
||||
OrgID: orgId,
|
||||
Data: ruleNode.GrafanaManagedAlert.Data,
|
||||
}
|
||||
if err := conditionValidator(cond); err != nil {
|
||||
return nil, fmt.Errorf("failed to validate condition of alert rule %s: %w", ruleNode.GrafanaManagedAlert.Title, err)
|
||||
}
|
||||
}
|
||||
|
||||
newAlertRule := ngmodels.AlertRule{
|
||||
OrgID: orgId,
|
||||
Title: ruleNode.GrafanaManagedAlert.Title,
|
||||
Condition: ruleNode.GrafanaManagedAlert.Condition,
|
||||
Data: ruleNode.GrafanaManagedAlert.Data,
|
||||
UID: ruleNode.GrafanaManagedAlert.UID,
|
||||
IntervalSeconds: intervalSeconds,
|
||||
NamespaceUID: namespace.Uid,
|
||||
RuleGroup: groupName,
|
||||
NoDataState: noDataState,
|
||||
ExecErrState: errorState,
|
||||
}
|
||||
|
||||
if ruleNode.ApiRuleNode != nil {
|
||||
newAlertRule.For = time.Duration(ruleNode.ApiRuleNode.For)
|
||||
newAlertRule.Annotations = ruleNode.ApiRuleNode.Annotations
|
||||
newAlertRule.Labels = ruleNode.ApiRuleNode.Labels
|
||||
|
||||
dashUID := ruleNode.ApiRuleNode.Annotations[ngmodels.DashboardUIDAnnotation]
|
||||
panelID := ruleNode.ApiRuleNode.Annotations[ngmodels.PanelIDAnnotation]
|
||||
|
||||
if dashUID != "" && panelID == "" || dashUID == "" && panelID != "" {
|
||||
return nil, fmt.Errorf("both annotations %s and %s must be specified", ngmodels.DashboardUIDAnnotation, ngmodels.PanelIDAnnotation)
|
||||
}
|
||||
|
||||
if dashUID != "" {
|
||||
panelIDValue, err := strconv.ParseInt(panelID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("annotation %s must be a valid integer Panel ID", ngmodels.PanelIDAnnotation)
|
||||
}
|
||||
newAlertRule.DashboardUID = &dashUID
|
||||
newAlertRule.PanelID = &panelIDValue
|
||||
}
|
||||
}
|
||||
|
||||
return &newAlertRule, nil
|
||||
}
|
||||
|
||||
// validateRuleGroup validates API model (definitions.PostableRuleGroupConfig) and converts it to a collection of models.AlertRule.
|
||||
// Returns a slice that contains all rules described by API model or error if either group specification or an alert definition is not valid.
|
||||
func validateRuleGroup(
|
||||
ruleGroupConfig *apimodels.PostableRuleGroupConfig,
|
||||
orgId int64,
|
||||
namespace *models.Folder,
|
||||
conditionValidator func(ngmodels.Condition) error,
|
||||
cfg *setting.UnifiedAlertingSettings) ([]*ngmodels.AlertRule, error) {
|
||||
if ruleGroupConfig.Name == "" {
|
||||
return nil, errors.New("rule group name cannot be empty")
|
||||
}
|
||||
|
||||
if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength {
|
||||
return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength)
|
||||
}
|
||||
|
||||
interval := time.Duration(ruleGroupConfig.Interval)
|
||||
if interval == 0 {
|
||||
// if group interval is 0 (undefined) then we automatically fall back to the default interval
|
||||
interval = cfg.DefaultRuleEvaluationInterval
|
||||
}
|
||||
|
||||
if interval < 0 || int64(interval.Seconds())%int64(cfg.BaseInterval.Seconds()) != 0 {
|
||||
return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(cfg.BaseInterval.Seconds()))
|
||||
}
|
||||
|
||||
// TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval
|
||||
|
||||
result := make([]*ngmodels.AlertRule, 0, len(ruleGroupConfig.Rules))
|
||||
uids := make(map[string]int, cap(result))
|
||||
for idx := range ruleGroupConfig.Rules {
|
||||
rule, err := validateRuleNode(&ruleGroupConfig.Rules[idx], ruleGroupConfig.Name, interval, orgId, namespace, conditionValidator, cfg)
|
||||
// TODO do not stop on the first failure but return all failures
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rule specification at index [%d]: %w", idx, err)
|
||||
}
|
||||
if rule.UID != "" {
|
||||
if existingIdx, ok := uids[rule.UID]; ok {
|
||||
return nil, fmt.Errorf("rule [%d] has UID %s that is already assigned to another rule at index %d", idx, rule.UID, existingIdx)
|
||||
}
|
||||
uids[rule.UID] = idx
|
||||
}
|
||||
result = append(result, rule)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/rand"
|
||||
|
||||
models2 "github.com/grafana/grafana/pkg/models"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var allNoData = []apimodels.NoDataState{
|
||||
apimodels.OK,
|
||||
apimodels.NoData,
|
||||
apimodels.Alerting,
|
||||
}
|
||||
|
||||
var allExecError = []apimodels.ExecutionErrorState{
|
||||
apimodels.ErrorErrState,
|
||||
apimodels.AlertingErrState,
|
||||
}
|
||||
|
||||
func config(t *testing.T) *setting.UnifiedAlertingSettings {
|
||||
t.Helper()
|
||||
baseInterval := time.Duration(rand.Intn(99)+1) * time.Second
|
||||
result := &setting.UnifiedAlertingSettings{
|
||||
BaseInterval: baseInterval,
|
||||
DefaultRuleEvaluationInterval: baseInterval * time.Duration(rand.Intn(9)+1),
|
||||
}
|
||||
t.Logf("Config Base interval is [%v]", result.BaseInterval)
|
||||
return result
|
||||
}
|
||||
|
||||
func validRule() apimodels.PostableExtendedRuleNode {
|
||||
return apimodels.PostableExtendedRuleNode{
|
||||
ApiRuleNode: &apimodels.ApiRuleNode{
|
||||
For: model.Duration(rand.Int63n(1000)),
|
||||
Labels: map[string]string{
|
||||
"test-label": "data",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"test-annotation": "data",
|
||||
},
|
||||
},
|
||||
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
|
||||
Title: fmt.Sprintf("TEST-ALERT-%d", rand.Int63()),
|
||||
Condition: "A",
|
||||
Data: []models.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
QueryType: "TEST",
|
||||
RelativeTimeRange: models.RelativeTimeRange{
|
||||
From: 10,
|
||||
To: 0,
|
||||
},
|
||||
DatasourceUID: "DATASOURCE_TEST",
|
||||
Model: nil,
|
||||
},
|
||||
},
|
||||
UID: util.GenerateShortUID(),
|
||||
NoDataState: allNoData[rand.Intn(len(allNoData)-1)],
|
||||
ExecErrState: allExecError[rand.Intn(len(allExecError)-1)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.PostableExtendedRuleNode) apimodels.PostableRuleGroupConfig {
|
||||
return apimodels.PostableRuleGroupConfig{
|
||||
Name: "TEST-ALERTS-" + util.GenerateShortUID(),
|
||||
Interval: model.Duration(cfg.BaseInterval * time.Duration(rand.Int63n(10))),
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func randFolder() *models2.Folder {
|
||||
return &models2.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,
|
||||
HasAcl: false,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuleGroup(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
|
||||
rules := make([]apimodels.PostableExtendedRuleNode, 0, rand.Intn(4)+1)
|
||||
for i := 0; i < cap(rules); i++ {
|
||||
rules = append(rules, validRule())
|
||||
}
|
||||
cfg := config(t)
|
||||
|
||||
t.Run("should validate struct and rules", func(t *testing.T) {
|
||||
g := validGroup(cfg, rules...)
|
||||
conditionValidations := 0
|
||||
alerts, err := validateRuleGroup(&g, orgId, folder, func(condition models.Condition) error {
|
||||
conditionValidations++
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, alerts, len(rules))
|
||||
require.Equal(t, len(rules), conditionValidations)
|
||||
})
|
||||
t.Run("should default to default interval from config if group interval is 0", func(t *testing.T) {
|
||||
g := validGroup(cfg, rules...)
|
||||
g.Interval = 0
|
||||
alerts, err := validateRuleGroup(&g, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
for _, alert := range alerts {
|
||||
require.Equal(t, int64(cfg.DefaultRuleEvaluationInterval.Seconds()), alert.IntervalSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRuleGroupFailures(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
cfg := config(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
group func() *apimodels.PostableRuleGroupConfig
|
||||
assert func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error)
|
||||
}{
|
||||
{
|
||||
name: "fail if title is empty",
|
||||
group: func() *apimodels.PostableRuleGroupConfig {
|
||||
g := validGroup(cfg)
|
||||
g.Name = ""
|
||||
return &g
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if title is too long",
|
||||
group: func() *apimodels.PostableRuleGroupConfig {
|
||||
g := validGroup(cfg)
|
||||
for len(g.Name) < store.AlertRuleMaxRuleGroupNameLength {
|
||||
g.Name += g.Name
|
||||
}
|
||||
return &g
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if interval is negative",
|
||||
group: func() *apimodels.PostableRuleGroupConfig {
|
||||
g := validGroup(cfg)
|
||||
g.Interval = model.Duration(-(rand.Int63n(1000) + 1))
|
||||
return &g
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if interval is not aligned with base interval",
|
||||
group: func() *apimodels.PostableRuleGroupConfig {
|
||||
g := validGroup(cfg)
|
||||
g.Interval = model.Duration(cfg.BaseInterval + time.Duration(rand.Intn(10)+1)*time.Second)
|
||||
return &g
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if two rules have same UID",
|
||||
group: func() *apimodels.PostableRuleGroupConfig {
|
||||
r1 := validRule()
|
||||
r2 := validRule()
|
||||
uid := util.GenerateShortUID()
|
||||
r1.GrafanaManagedAlert.UID = uid
|
||||
r2.GrafanaManagedAlert.UID = uid
|
||||
g := validGroup(cfg, r1, r2)
|
||||
return &g
|
||||
},
|
||||
assert: func(t *testing.T, apiModel *apimodels.PostableRuleGroupConfig, err error) {
|
||||
require.Contains(t, err.Error(), apiModel.Rules[0].GrafanaManagedAlert.UID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
g := testCase.group()
|
||||
_, err := validateRuleGroup(g, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.Error(t, err)
|
||||
if testCase.assert != nil {
|
||||
testCase.assert(t, g, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuleNode_NoUID(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
name := util.GenerateShortUID()
|
||||
var cfg = config(t)
|
||||
interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rule func() *apimodels.PostableExtendedRuleNode
|
||||
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule)
|
||||
}{
|
||||
{
|
||||
name: "coverts api model to AlertRule",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, int64(0), alert.ID)
|
||||
require.Equal(t, orgId, alert.OrgID)
|
||||
require.Equal(t, api.GrafanaManagedAlert.Title, alert.Title)
|
||||
require.Equal(t, api.GrafanaManagedAlert.Condition, alert.Condition)
|
||||
require.Equal(t, api.GrafanaManagedAlert.Data, alert.Data)
|
||||
require.Equal(t, time.Time{}, alert.Updated)
|
||||
require.Equal(t, int64(interval.Seconds()), alert.IntervalSeconds)
|
||||
require.Equal(t, int64(0), alert.Version)
|
||||
require.Equal(t, api.GrafanaManagedAlert.UID, alert.UID)
|
||||
require.Equal(t, folder.Uid, alert.NamespaceUID)
|
||||
require.Nil(t, alert.DashboardUID)
|
||||
require.Nil(t, alert.PanelID)
|
||||
require.Equal(t, name, alert.RuleGroup)
|
||||
require.Equal(t, models.NoDataState(api.GrafanaManagedAlert.NoDataState), alert.NoDataState)
|
||||
require.Equal(t, models.ExecutionErrorState(api.GrafanaManagedAlert.ExecErrState), alert.ExecErrState)
|
||||
require.Equal(t, time.Duration(api.ApiRuleNode.For), alert.For)
|
||||
require.Equal(t, api.ApiRuleNode.Annotations, alert.Annotations)
|
||||
require.Equal(t, api.ApiRuleNode.Labels, alert.Labels)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "coverts api without ApiRuleNode",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode = nil
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, time.Duration(0), alert.For)
|
||||
require.Nil(t, alert.Annotations)
|
||||
require.Nil(t, alert.Labels)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaults to NoData if NoDataState is empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.NoDataState = ""
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, models.NoData, alert.NoDataState)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaults to Alerting if ExecErrState is empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.ExecErrState = ""
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, models.AlertingErrState, alert.ExecErrState)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extracts Dashboard UID and Panel Id from annotations",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
models.PanelIDAnnotation: strconv.Itoa(rand.Int()),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID)
|
||||
panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(panelId), *alert.PanelID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
r := testCase.rule()
|
||||
r.GrafanaManagedAlert.UID = ""
|
||||
|
||||
alert, err := validateRuleNode(r, name, interval, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
testCase.assert(t, r, alert)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("accepts empty group name", func(t *testing.T) {
|
||||
r := validRule()
|
||||
alert, err := validateRuleNode(&r, "", interval, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", alert.RuleGroup)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRuleNodeFailures_NoUID(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
cfg := config(t)
|
||||
successValidation := func(condition models.Condition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
interval *time.Duration
|
||||
rule func() *apimodels.PostableExtendedRuleNode
|
||||
conditionValidation func(condition models.Condition) error
|
||||
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error)
|
||||
allowedIfNoUId bool
|
||||
}{
|
||||
{
|
||||
name: "fail if GrafanaManagedAlert is not specified",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert = nil
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if title is empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Title = ""
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if title is too long",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength {
|
||||
r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if NoDataState is not known",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.NoDataState = apimodels.NoDataState(util.GenerateShortUID())
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if ExecErrState is not known",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.ExecErrState = apimodels.ExecutionErrorState(util.GenerateShortUID())
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if there are not data (nil)",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Data = nil
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if there are not data (empty)",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0, 1)
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if validator function returns error",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
return &r
|
||||
},
|
||||
conditionValidation: func(condition models.Condition) error {
|
||||
return errors.New("BAD alert condition")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if Dashboard UID is specified but not Panel ID",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if Dashboard UID is specified and Panel ID is NaN",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
models.PanelIDAnnotation: util.GenerateShortUID(),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if PanelID is specified but not Dashboard UID ",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.PanelIDAnnotation: "0",
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
r := testCase.rule()
|
||||
if r.GrafanaManagedAlert != nil {
|
||||
r.GrafanaManagedAlert.UID = ""
|
||||
}
|
||||
f := successValidation
|
||||
if testCase.conditionValidation != nil {
|
||||
f = testCase.conditionValidation
|
||||
}
|
||||
|
||||
interval := cfg.BaseInterval
|
||||
if testCase.interval != nil {
|
||||
interval = *testCase.interval
|
||||
}
|
||||
|
||||
_, err := validateRuleNode(r, "", interval, orgId, folder, f, cfg)
|
||||
require.Error(t, err)
|
||||
if testCase.assert != nil {
|
||||
testCase.assert(t, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuleNode_UID(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
name := util.GenerateShortUID()
|
||||
var cfg = config(t)
|
||||
interval := cfg.BaseInterval * time.Duration(rand.Int63n(10)+1)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rule func() *apimodels.PostableExtendedRuleNode
|
||||
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, rule *models.AlertRule)
|
||||
}{
|
||||
{
|
||||
name: "use empty Title",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Title = ""
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, "", alert.Title)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "use empty NoData if NoDataState is empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.NoDataState = ""
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, models.NoDataState(""), alert.NoDataState)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "use empty Alerting if ExecErrState is empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.ExecErrState = ""
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, models.ExecutionErrorState(""), alert.ExecErrState)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "use empty Condition and Data if they are empty",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Condition = ""
|
||||
r.GrafanaManagedAlert.Data = nil
|
||||
if rand.Int63()%2 == 0 {
|
||||
r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0)
|
||||
}
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, "", alert.Condition)
|
||||
require.Len(t, alert.Data, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extracts Dashboard UID and Panel Id from annotations",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
models.PanelIDAnnotation: strconv.Itoa(rand.Int()),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
assert: func(t *testing.T, api *apimodels.PostableExtendedRuleNode, alert *models.AlertRule) {
|
||||
require.Equal(t, api.ApiRuleNode.Annotations[models.DashboardUIDAnnotation], *alert.DashboardUID)
|
||||
panelId, err := strconv.Atoi(api.ApiRuleNode.Annotations[models.PanelIDAnnotation])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(panelId), *alert.PanelID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
r := testCase.rule()
|
||||
alert, err := validateRuleNode(r, name, interval, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
testCase.assert(t, r, alert)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("accepts empty group name", func(t *testing.T) {
|
||||
r := validRule()
|
||||
alert, err := validateRuleNode(&r, "", interval, orgId, folder, func(condition models.Condition) error {
|
||||
return nil
|
||||
}, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", alert.RuleGroup)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRuleNodeFailures_UID(t *testing.T) {
|
||||
orgId := rand.Int63()
|
||||
folder := randFolder()
|
||||
cfg := config(t)
|
||||
successValidation := func(condition models.Condition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
interval *time.Duration
|
||||
rule func() *apimodels.PostableExtendedRuleNode
|
||||
conditionValidation func(condition models.Condition) error
|
||||
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error)
|
||||
}{
|
||||
{
|
||||
name: "fail if GrafanaManagedAlert is not specified",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert = nil
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if title is too long",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
for len(r.GrafanaManagedAlert.Title) < store.AlertRuleMaxTitleLength {
|
||||
r.GrafanaManagedAlert.Title += r.GrafanaManagedAlert.Title
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if there are not data (nil) but condition is set",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Data = nil
|
||||
r.GrafanaManagedAlert.Condition = "A"
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if there are not data (empty) but condition is set",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.GrafanaManagedAlert.Data = make([]models.AlertQuery, 0, 1)
|
||||
r.GrafanaManagedAlert.Condition = "A"
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if validator function returns error",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
return &r
|
||||
},
|
||||
conditionValidation: func(condition models.Condition) error {
|
||||
return errors.New("BAD alert condition")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if Dashboard UID is specified but not Panel ID",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if Dashboard UID is specified and Panel ID is NaN",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.DashboardUIDAnnotation: util.GenerateShortUID(),
|
||||
models.PanelIDAnnotation: util.GenerateShortUID(),
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail if PanelID is specified but not Dashboard UID ",
|
||||
rule: func() *apimodels.PostableExtendedRuleNode {
|
||||
r := validRule()
|
||||
r.ApiRuleNode.Annotations = map[string]string{
|
||||
models.PanelIDAnnotation: "0",
|
||||
}
|
||||
return &r
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
r := testCase.rule()
|
||||
f := successValidation
|
||||
if testCase.conditionValidation != nil {
|
||||
f = testCase.conditionValidation
|
||||
}
|
||||
|
||||
interval := cfg.BaseInterval
|
||||
if testCase.interval != nil {
|
||||
interval = *testCase.interval
|
||||
}
|
||||
|
||||
_, err := validateRuleNode(r, "", interval, orgId, folder, f, cfg)
|
||||
require.Error(t, err)
|
||||
if testCase.assert != nil {
|
||||
testCase.assert(t, r, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuleNodeIntervalFailures(t *testing.T) {
|
||||
cfg := config(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
}{
|
||||
{
|
||||
name: "fail if interval is negative",
|
||||
interval: -time.Duration(rand.Int63n(10)+1) * time.Second,
|
||||
},
|
||||
{
|
||||
name: "fail if interval is 0",
|
||||
interval: 0,
|
||||
},
|
||||
{
|
||||
name: "fail if interval is not multiple of base interval",
|
||||
interval: cfg.BaseInterval + time.Duration(rand.Int63n(int64(cfg.BaseInterval.Seconds())-2)+1)*time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
r := validRule()
|
||||
f := func(condition models.Condition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder(), f, cfg)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,13 @@ func validateCondition(ctx context.Context, c ngmodels.Condition, user *models.S
|
||||
return nil
|
||||
}
|
||||
|
||||
// conditionValidator returns a curried validateCondition that accepts only condition
|
||||
func conditionValidator(c *models.ReqContext, cache datasources.CacheService) func(ngmodels.Condition) error {
|
||||
return func(condition ngmodels.Condition) error {
|
||||
return validateCondition(c.Req.Context(), condition, c.SignedInUser, c.SkipCache, cache)
|
||||
}
|
||||
}
|
||||
|
||||
func validateQueriesAndExpressions(ctx context.Context, data []ngmodels.AlertQuery, user *models.SignedInUser, skipCache bool, datasourceCache datasources.CacheService) (map[string]struct{}, error) {
|
||||
refIDs := make(map[string]struct{})
|
||||
if len(data) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user