Alerting: update rules POST API to validate query and condition only for rules that changed. (#68667)

* replace condition validation with just structural validation
* validate conditions of only new and updated rules
* add integration tests for rule update\delete API

Co-authored-by: George Robinson <george.robinson@grafana.com>
This commit is contained in:
Yuri Tseretyan
2023-06-15 13:33:42 -04:00
committed by GitHub
parent 94881597d8
commit b963defa44
10 changed files with 542 additions and 108 deletions

View File

@@ -22,6 +22,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -302,9 +303,7 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro
return toNamespaceErrorResponse(err)
}
rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgID, namespace, func(condition ngmodels.Condition) error {
return srv.conditionValidator.Validate(eval.NewContext(c.Req.Context(), c.SignedInUser), condition)
}, srv.cfg)
rules, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgID, namespace, srv.cfg)
if err != nil {
return ErrResp(http.StatusBadRequest, err, "")
}
@@ -343,6 +342,10 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
return err
}
if err := validateQueries(c.Req.Context(), groupChanges, srv.conditionValidator, c.SignedInUser); err != nil {
return err
}
if err := verifyProvisionedRulesNotAffected(c.Req.Context(), srv.provenanceStore, c.OrgID, groupChanges); err != nil {
return err
}
@@ -508,3 +511,23 @@ func verifyProvisionedRulesNotAffected(ctx context.Context, provenanceStore prov
}
return fmt.Errorf("%w: alert rule group [%s]", errProvisionedResource, errorMsg.String())
}
func validateQueries(ctx context.Context, groupChanges *store.GroupDelta, validator ConditionValidator, user *user.SignedInUser) error {
if len(groupChanges.New) > 0 {
for _, rule := range groupChanges.New {
err := validator.Validate(eval.NewContext(ctx, user), rule.GetEvalCondition())
if err != nil {
return fmt.Errorf("%w '%s': %s", ngmodels.ErrAlertRuleFailedValidation, rule.Title, err.Error())
}
}
}
if len(groupChanges.Update) > 0 {
for _, upd := range groupChanges.Update {
err := validator.Validate(eval.NewContext(ctx, user), upd.New.GetEvalCondition())
if err != nil {
return fmt.Errorf("%w '%s' (UID: %s): %s", ngmodels.ErrAlertRuleFailedValidation, upd.New.Title, upd.New.UID, err.Error())
}
}
}
return nil
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/http"
@@ -518,6 +519,71 @@ func TestVerifyProvisionedRulesNotAffected(t *testing.T) {
})
}
func TestValidateQueries(t *testing.T) {
delta := store.GroupDelta{
New: []*models.AlertRule{
models.AlertRuleGen(func(rule *models.AlertRule) {
rule.Condition = "New"
})(),
},
Update: []store.RuleDelta{
{
Existing: models.AlertRuleGen(func(rule *models.AlertRule) {
rule.Condition = "Update_Existing"
})(),
New: models.AlertRuleGen(func(rule *models.AlertRule) {
rule.Condition = "Update_New"
})(),
Diff: nil,
},
},
Delete: []*models.AlertRule{
models.AlertRuleGen(func(rule *models.AlertRule) {
rule.Condition = "Deleted"
})(),
},
}
t.Run("should validate New and Updated only", func(t *testing.T) {
validator := &recordingConditionValidator{}
err := validateQueries(context.Background(), &delta, validator, nil)
require.NoError(t, err)
for _, condition := range validator.recorded {
if condition.Condition == "New" || condition.Condition == "Update_New" {
continue
}
assert.Failf(t, "validated unexpected condition", "condition '%s' was validated but should not", condition.Condition)
}
})
t.Run("should return rule validate error if fails on new rule", func(t *testing.T) {
validator := &recordingConditionValidator{
hook: func(c models.Condition) error {
if c.Condition == "New" {
return errors.New("test")
}
return nil
},
}
err := validateQueries(context.Background(), &delta, validator, nil)
require.Error(t, err)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
})
t.Run("should return rule validate error with UID if fails on updated rule", func(t *testing.T) {
validator := &recordingConditionValidator{
hook: func(c models.Condition) error {
if c.Condition == "Update_New" {
return errors.New("test")
}
return nil
},
}
err := validateQueries(context.Background(), &delta, validator, nil)
require.Error(t, err)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, delta.Update[0].New.UID)
})
}
func createServiceWithProvenanceStore(store *fakes.RuleStore, provenanceStore provisioning.ProvisioningStore) *RulerSrv {
svc := createService(store)
svc.provenanceStore = provenanceStore

View File

@@ -3,6 +3,7 @@ package api
import (
"errors"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/folder"
@@ -19,7 +20,6 @@ func validateRuleNode(
interval time.Duration,
orgId int64,
namespace *folder.Folder,
conditionValidator func(ngmodels.Condition) error,
cfg *setting.UnifiedAlertingSettings) (*ngmodels.AlertRule, error) {
intervalSeconds, err := validateInterval(cfg, interval)
if err != nil {
@@ -74,18 +74,14 @@ func validateRuleNode(
} else {
return nil, fmt.Errorf("%w: no queries or expressions are found", ngmodels.ErrAlertRuleFailedValidation)
}
} else {
err = validateCondition(ruleNode.GrafanaManagedAlert.Condition, ruleNode.GrafanaManagedAlert.Data)
if err != nil {
return nil, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err.Error())
}
}
queries := AlertQueriesFromApiAlertQueries(ruleNode.GrafanaManagedAlert.Data)
if len(queries) != 0 {
cond := ngmodels.Condition{
Condition: ruleNode.GrafanaManagedAlert.Condition,
Data: queries,
}
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,
@@ -117,6 +113,33 @@ func validateRuleNode(
return &newAlertRule, nil
}
func validateCondition(condition string, queries []apimodels.AlertQuery) error {
if condition == "" {
return errors.New("condition cannot be empty")
}
if len(queries) == 0 {
return errors.New("no query/expressions specified")
}
refIDs := make(map[string]int, len(queries))
for idx, query := range queries {
if query.RefID == "" {
return fmt.Errorf("refID is not specified for data query/expression at index %d", idx)
}
if usedIdx, ok := refIDs[query.RefID]; ok {
return fmt.Errorf("refID '%s' is already used by query/expression at index %d", query.RefID, usedIdx)
}
refIDs[query.RefID] = idx
}
if _, ok := refIDs[condition]; !ok {
ids := make([]string, 0, len(refIDs))
for id := range refIDs {
ids = append(ids, id)
}
return fmt.Errorf("condition %s does not exist, must be one of [%s]", condition, strings.Join(ids, ","))
}
return nil
}
func validateInterval(cfg *setting.UnifiedAlertingSettings, interval time.Duration) (int64, error) {
intervalSeconds := int64(interval.Seconds())
@@ -155,7 +178,6 @@ func validateRuleGroup(
ruleGroupConfig *apimodels.PostableRuleGroupConfig,
orgId int64,
namespace *folder.Folder,
conditionValidator func(ngmodels.Condition) error,
cfg *setting.UnifiedAlertingSettings) ([]*ngmodels.AlertRuleWithOptionals, error) {
if ruleGroupConfig.Name == "" {
return nil, errors.New("rule group name cannot be empty")
@@ -180,7 +202,7 @@ func validateRuleGroup(
result := make([]*ngmodels.AlertRuleWithOptionals, 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)
rule, err := validateRuleNode(&ruleGroupConfig.Rules[idx], ruleGroupConfig.Name, interval, orgId, namespace, 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)

View File

@@ -1,12 +1,12 @@
package api
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"golang.org/x/exp/rand"
@@ -98,6 +98,90 @@ func randFolder() *folder.Folder {
}
}
func TestValidateCondition(t *testing.T) {
testcases := []struct {
name string
condition string
data []apimodels.AlertQuery
errorMsg string
}{
{
name: "error when condition is empty",
condition: "",
data: []apimodels.AlertQuery{},
errorMsg: "condition cannot be empty",
},
{
name: "error when data is empty",
condition: "A",
data: []apimodels.AlertQuery{},
errorMsg: "no query/expressions specified",
},
{
name: "error when condition does not exist",
condition: "A",
data: []apimodels.AlertQuery{
{
RefID: "B",
},
{
RefID: "C",
},
},
errorMsg: "condition A does not exist, must be one of [B,C]",
},
{
name: "error when duplicated refId",
condition: "A",
data: []apimodels.AlertQuery{
{
RefID: "A",
},
{
RefID: "A",
},
},
errorMsg: "refID 'A' is already used by query/expression at index 0",
},
{
name: "error when refId is empty",
condition: "A",
data: []apimodels.AlertQuery{
{
RefID: "",
},
{
RefID: "A",
},
},
errorMsg: "refID is not specified for data query/expression at index 0",
},
{
name: "valid case",
condition: "B",
data: []apimodels.AlertQuery{
{
RefID: "A",
},
{
RefID: "B",
},
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
err := validateCondition(tc.condition, tc.data)
if tc.errorMsg == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.errorMsg)
}
})
}
}
func TestValidateRuleGroup(t *testing.T) {
orgId := rand.Int63()
folder := randFolder()
@@ -110,22 +194,15 @@ func TestValidateRuleGroup(t *testing.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)
alerts, err := validateRuleGroup(&g, orgId, folder, 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)
alerts, err := validateRuleGroup(&g, orgId, folder, cfg)
require.NoError(t, err)
for _, alert := range alerts {
require.Equal(t, int64(cfg.DefaultRuleEvaluationInterval.Seconds()), alert.IntervalSeconds)
@@ -140,9 +217,7 @@ func TestValidateRuleGroup(t *testing.T) {
isPaused = !(isPaused)
}
g := validGroup(cfg, rules...)
alerts, err := validateRuleGroup(&g, orgId, folder, func(condition models.Condition) error {
return nil
}, cfg)
alerts, err := validateRuleGroup(&g, orgId, folder, cfg)
require.NoError(t, err)
for _, alert := range alerts {
require.True(t, alert.HasPause)
@@ -214,9 +289,7 @@ func TestValidateRuleGroupFailures(t *testing.T) {
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)
_, err := validateRuleGroup(g, orgId, folder, cfg)
require.Error(t, err)
if testCase.assert != nil {
testCase.assert(t, g, err)
@@ -323,9 +396,7 @@ func TestValidateRuleNode_NoUID(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)
alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg)
require.NoError(t, err)
testCase.assert(t, r, alert)
})
@@ -333,9 +404,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) {
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)
alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg)
require.NoError(t, err)
require.Equal(t, "", alert.RuleGroup)
})
@@ -345,17 +414,13 @@ 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 string
interval *time.Duration
rule func() *apimodels.PostableExtendedRuleNode
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error)
allowedIfNoUId bool
}{
{
name: "fail if GrafanaManagedAlert is not specified",
@@ -415,16 +480,6 @@ func TestValidateRuleNodeFailures_NoUID(t *testing.T) {
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 {
@@ -456,6 +511,38 @@ func TestValidateRuleNodeFailures_NoUID(t *testing.T) {
return &r
},
},
{
name: "fail if Condition is empty",
rule: func() *apimodels.PostableExtendedRuleNode {
r := validRule()
r.GrafanaManagedAlert.Condition = ""
return &r
},
},
{
name: "fail if Data is empty",
rule: func() *apimodels.PostableExtendedRuleNode {
r := validRule()
r.GrafanaManagedAlert.Data = nil
return &r
},
},
{
name: "fail if Condition does not exist",
rule: func() *apimodels.PostableExtendedRuleNode {
r := validRule()
r.GrafanaManagedAlert.Condition = uuid.NewString()
return &r
},
},
{
name: "fail if Data has duplicate ref ID",
rule: func() *apimodels.PostableExtendedRuleNode {
r := validRule()
r.GrafanaManagedAlert.Data = append(r.GrafanaManagedAlert.Data, r.GrafanaManagedAlert.Data...)
return &r
},
},
}
for _, testCase := range testCases {
@@ -464,17 +551,13 @@ func TestValidateRuleNodeFailures_NoUID(t *testing.T) {
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)
_, err := validateRuleNode(r, "", interval, orgId, folder, cfg)
require.Error(t, err)
if testCase.assert != nil {
testCase.assert(t, r, err)
@@ -566,9 +649,7 @@ func TestValidateRuleNode_UID(t *testing.T) {
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)
alert, err := validateRuleNode(r, name, interval, orgId, folder, cfg)
require.NoError(t, err)
testCase.assert(t, r, alert)
})
@@ -576,9 +657,7 @@ func TestValidateRuleNode_UID(t *testing.T) {
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)
alert, err := validateRuleNode(&r, "", interval, orgId, folder, cfg)
require.NoError(t, err)
require.Equal(t, "", alert.RuleGroup)
})
@@ -588,16 +667,12 @@ 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 string
interval *time.Duration
rule func() *apimodels.PostableExtendedRuleNode
assert func(t *testing.T, model *apimodels.PostableExtendedRuleNode, err error)
}{
{
name: "fail if GrafanaManagedAlert is not specified",
@@ -635,16 +710,6 @@ func TestValidateRuleNodeFailures_UID(t *testing.T) {
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 {
@@ -681,17 +746,13 @@ func TestValidateRuleNodeFailures_UID(t *testing.T) {
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)
_, err := validateRuleNode(r, "", interval, orgId, folder, cfg)
require.Error(t, err)
if testCase.assert != nil {
testCase.assert(t, r, err)
@@ -724,11 +785,7 @@ func TestValidateRuleNodeIntervalFailures(t *testing.T) {
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)
_, err := validateRuleNode(&r, util.GenerateShortUID(), testCase.interval, rand.Int63(), randFolder(), cfg)
require.Error(t, err)
})
}

View File

@@ -55,9 +55,6 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext,
UID: body.NamespaceUID,
Title: body.NamespaceTitle,
},
func(condition ngmodels.Condition) error {
return srv.evaluator.Validate(eval.NewContext(c.Req.Context(), c.SignedInUser), condition)
},
srv.cfg,
)
if err != nil {

View File

@@ -147,6 +147,7 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
rule.GrafanaManagedAlert.Condition = data2.RefID
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
@@ -180,6 +181,7 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
rule.GrafanaManagedAlert.Condition = data2.RefID
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",

View File

@@ -13,6 +13,7 @@ import (
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/auth"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
models2 "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
@@ -159,3 +160,18 @@ func Test_containsProvisionedAlerts(t *testing.T) {
require.Falsef(t, containsProvisionedAlerts(provenance, rules), "the group of rules is not expected to be provisioned but it is. Provenances: %v", provenance)
})
}
type recordingConditionValidator struct {
recorded []models2.Condition
hook func(c models2.Condition) error
}
func (r *recordingConditionValidator) Validate(_ eval.EvaluationContext, condition models2.Condition) error {
r.recorded = append(r.recorded, condition)
if r.hook != nil {
return r.hook(condition)
}
return nil
}
var _ ConditionValidator = &recordingConditionValidator{}