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:
co-authored by
George Robinson
parent
94881597d8
commit
b963defa44
@@ -875,6 +875,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
rulegroup string
|
||||
interval model.Duration
|
||||
rule apimodels.PostableExtendedRuleNode
|
||||
expectedCode int
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
@@ -1042,7 +1043,18 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedMessage: "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: failed to build query 'A': data source not found",
|
||||
expectedCode: func() int {
|
||||
if setting.IsEnterprise {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}(),
|
||||
expectedMessage: func() string {
|
||||
if setting.IsEnterprise {
|
||||
return "user is not authorized to create a new alert rule 'AlwaysFiring' because the user does not have read permissions for one or many datasources the rule uses"
|
||||
}
|
||||
return "failed to update rule group: invalid alert rule 'AlwaysFiring': failed to build query 'A': data source not found"
|
||||
}(),
|
||||
},
|
||||
{
|
||||
desc: "alert rule with invalid condition",
|
||||
@@ -1072,7 +1084,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedMessage: "invalid rule specification at index [0]: failed to validate condition of alert rule AlwaysFiring: condition B does not exist, must be one of [A]",
|
||||
expectedMessage: "invalid rule specification at index [0]: invalid alert rule: condition B does not exist, must be one of [A]",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1091,8 +1103,11 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedMessage, res.Message)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
expectedCode := tc.expectedCode
|
||||
if expectedCode == 0 {
|
||||
expectedCode = http.StatusBadRequest
|
||||
}
|
||||
assert.Equal(t, expectedCode, status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,16 +10,21 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
datasourceService "github.com/grafana/grafana/pkg/services/datasources/service"
|
||||
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/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -862,14 +868,40 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
AppModeProduction: true,
|
||||
})
|
||||
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
|
||||
permissionsStore := resourcepermissions.NewStore(store)
|
||||
|
||||
// Create a user to make authenticated requests
|
||||
createUser(t, store, user.CreateUserCommand{
|
||||
userID := createUser(t, store, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleEditor),
|
||||
Password: "password",
|
||||
Login: "grafana",
|
||||
})
|
||||
|
||||
if setting.IsEnterprise {
|
||||
// add blanket access to data sources.
|
||||
_, err := permissionsStore.SetUserResourcePermission(context.Background(),
|
||||
1,
|
||||
accesscontrol.User{ID: userID},
|
||||
resourcepermissions.SetResourcePermissionCommand{
|
||||
Actions: []string{
|
||||
datasources.ActionQuery,
|
||||
},
|
||||
Resource: datasources.ScopeRoot,
|
||||
ResourceID: "*",
|
||||
ResourceAttribute: "uid",
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a user to make authenticated requests
|
||||
createUser(t, store, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleAdmin),
|
||||
Password: "admin",
|
||||
Login: "admin",
|
||||
})
|
||||
|
||||
adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
|
||||
|
||||
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
folder1Title := "folder1"
|
||||
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
|
||||
@@ -893,6 +925,80 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
|
||||
})
|
||||
t.Run("when data source missing", func(t *testing.T) {
|
||||
var groupName string
|
||||
{
|
||||
ds1 := adminClient.CreateTestDatasource(t)
|
||||
group := generateAlertRuleGroup(3, alertRuleGen(withDatasourceQuery(ds1.Body.Datasource.UID)))
|
||||
|
||||
status, body := client.PostRulesGroup(t, folder1Title, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
require.Len(t, group.Rules, 3)
|
||||
|
||||
adminClient.DeleteDatasource(t, ds1.Body.Datasource.UID)
|
||||
|
||||
// expire datasource caching
|
||||
<-time.After(datasourceService.DefaultCacheTTL + 1*time.Second) // TODO delete when TTL could be configured
|
||||
|
||||
groupName = group.Name
|
||||
}
|
||||
|
||||
t.Run("noop should not fail", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
status, body := client.PostRulesGroup(t, folder1Title, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
})
|
||||
t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString()
|
||||
status, body := client.PostRulesGroup(t, folder1Title, &group)
|
||||
|
||||
if status == http.StatusAccepted {
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated")
|
||||
}
|
||||
require.Equalf(t, http.StatusBadRequest, status, "expected BadRequest. Response: %s", body)
|
||||
assert.Contains(t, body, "data source not found")
|
||||
})
|
||||
t.Run("should let delete broken rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
// remove the last rule.
|
||||
group.Rules = group.Rules[0 : len(group.Rules)-1]
|
||||
status, body := client.PostRulesGroup(t, folder1Title, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Len(t, group.Rules, 2)
|
||||
})
|
||||
t.Run("should let fix single rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
ds2 := adminClient.CreateTestDatasource(t)
|
||||
withDatasourceQuery(ds2.Body.Datasource.UID)(&group.Rules[0])
|
||||
status, body := client.PostRulesGroup(t, folder1Title, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Equal(t, ds2.Body.Datasource.UID, group.Rules[0].GrafanaManagedAlert.Data[0].DatasourceUID)
|
||||
})
|
||||
t.Run("should let delete group", func(t *testing.T) {
|
||||
status, body := client.DeleteRulesGroup(t, folder1Title, groupName)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig {
|
||||
|
||||
@@ -10,12 +10,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
|
||||
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/quota"
|
||||
@@ -89,10 +90,12 @@ func getBody(t *testing.T, body io.ReadCloser) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func alertRuleGen() func() apimodels.PostableExtendedRuleNode {
|
||||
type ruleMutator func(r *apimodels.PostableExtendedRuleNode)
|
||||
|
||||
func alertRuleGen(mutators ...ruleMutator) func() apimodels.PostableExtendedRuleNode {
|
||||
return func() apimodels.PostableExtendedRuleNode {
|
||||
forDuration := model.Duration(10 * time.Second)
|
||||
return apimodels.PostableExtendedRuleNode{
|
||||
rule := apimodels.PostableExtendedRuleNode{
|
||||
ApiRuleNode: &apimodels.ApiRuleNode{
|
||||
For: &forDuration,
|
||||
Labels: map[string]string{"label1": "val1"},
|
||||
@@ -117,6 +120,69 @@ func alertRuleGen() func() apimodels.PostableExtendedRuleNode {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, mutator := range mutators {
|
||||
mutator(&rule)
|
||||
}
|
||||
return rule
|
||||
}
|
||||
}
|
||||
|
||||
func withDatasourceQuery(uid string) func(r *apimodels.PostableExtendedRuleNode) {
|
||||
data := []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(600 * time.Second),
|
||||
To: 0,
|
||||
},
|
||||
DatasourceUID: uid,
|
||||
Model: json.RawMessage(fmt.Sprintf(`{
|
||||
"refId": "A",
|
||||
"hide": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "%s"
|
||||
},
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 5,
|
||||
"labels": "series=series-$seriesIndex"
|
||||
}`, uid)),
|
||||
},
|
||||
{
|
||||
RefID: "B",
|
||||
DatasourceUID: expr.DatasourceType,
|
||||
Model: json.RawMessage(`{
|
||||
"type": "reduce",
|
||||
"reducer": "last",
|
||||
"expression": "A"
|
||||
}`),
|
||||
},
|
||||
{
|
||||
RefID: "C",
|
||||
DatasourceUID: expr.DatasourceType,
|
||||
Model: json.RawMessage(`{
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"evaluator": {
|
||||
"params": [
|
||||
0
|
||||
],
|
||||
"type": "gt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "B"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
return func(r *apimodels.PostableExtendedRuleNode) {
|
||||
r.GrafanaManagedAlert.Data = data
|
||||
r.GrafanaManagedAlert.Condition = "C"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +192,7 @@ func generateAlertRuleGroup(rulesCount int, gen func() apimodels.PostableExtende
|
||||
rules = append(rules, gen())
|
||||
}
|
||||
return apimodels.PostableRuleGroupConfig{
|
||||
Name: "arulegroup-" + util.GenerateShortUID(),
|
||||
Name: "arulegroup-" + uuid.NewString(),
|
||||
Interval: model.Duration(10 * time.Second),
|
||||
Rules: rules,
|
||||
}
|
||||
@@ -280,6 +346,24 @@ func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (a apiClient) DeleteRulesGroup(t *testing.T, folder string, group string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/%s/%s", a.url, folder, group)
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) apimodels.RuleGroupConfigResponse {
|
||||
t.Helper()
|
||||
u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/%s/%s", a.url, folder, group)
|
||||
@@ -353,3 +437,49 @@ func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableE
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (a apiClient) CreateTestDatasource(t *testing.T) (result api.CreateOrUpdateDatasourceResponse) {
|
||||
t.Helper()
|
||||
|
||||
payload := fmt.Sprintf(`{"name":"TestData-%s","type":"testdata","access":"proxy","isDefault":false}`, uuid.NewString())
|
||||
buf := bytes.Buffer{}
|
||||
buf.Write([]byte(payload))
|
||||
|
||||
u := fmt.Sprintf("%s/api/datasources", a.url)
|
||||
|
||||
// nolint:gosec
|
||||
resp, err := http.Post(u, "application/json", &buf)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
require.Failf(t, "failed to create data source", "API request to create a datasource failed. Status code: %d, response: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(fmt.Sprintf(`{ "body": %s }`, string(b))), &result))
|
||||
return result
|
||||
}
|
||||
|
||||
func (a apiClient) DeleteDatasource(t *testing.T, uid string) {
|
||||
t.Helper()
|
||||
|
||||
u := fmt.Sprintf("%s/api/datasources/uid/%s", a.url, uid)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
require.Failf(t, "failed to create data source", "API request to create a datasource failed. Status code: %d, response: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user