[Alerting]: Implement test rule API route (#32837)

* [Alerting]: Implement test rule API route

* Apply suggestions from code review

* Call /query instead of /query_range
This commit is contained in:
Sofia Papagiannaki 2021-04-13 20:58:34 +03:00 committed by GitHub
parent 15978900a9
commit e7ff04a167
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 293 additions and 112 deletions

2
go.sum
View File

@ -1044,6 +1044,7 @@ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
@ -1450,6 +1451,7 @@ github.com/prometheus/prometheus v1.8.2-0.20210217141258-a6be548dbc17 h1:VN3p3Nb
github.com/prometheus/prometheus v1.8.2-0.20210217141258-a6be548dbc17/go.mod h1:dv3B1syqmkrkmo665MPCU6L8PbTXIiUeg/OEQULLNxA=
github.com/prometheus/statsd_exporter v0.15.0/go.mod h1:Dv8HnkoLQkeEjkIE4/2ndAA7WL1zHKK7WMqFQqu72rw=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/quasilyte/go-ruleguard/dsl/fluent v0.0.0-20201222093424-5d7e62a465d3 h1:eL7x4/zMnlquMxYe7V078BD7MGskZ0daGln+SJCVzuY=
github.com/quasilyte/go-ruleguard/dsl/fluent v0.0.0-20201222093424-5d7e62a465d3/go.mod h1:P7JlQWFT7jDcFZMtUPQbtGzzzxva3rBn6oIF+LPwFcM=
github.com/rafaeljusto/redigomock v0.0.0-20190202135759-257e089e14a1/go.mod h1:JaY6n2sDr+z2WTsXkOmNRUfDy6FN0L6Nk7x06ndm4tY=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=

View File

@ -77,8 +77,13 @@ func (api *API) RegisterAPIEndpoints() {
NewLotexRuler(proxy, logger),
RulerSrv{store: api.RuleStore, log: logger},
))
// Register endpoints for testing evaluation of rules and notification channels.
api.RegisterTestingApiEndpoints(TestingApiMock{log: logger})
api.RegisterTestingApiEndpoints(TestingApiSrv{
AlertingProxy: proxy,
Cfg: api.Cfg,
DataService: api.DataService,
DatasourceCache: api.DatasourceCache,
log: logger,
})
// Legacy routes; they will be removed in v8
api.RouteRegister.Group("/api/alert-definitions", func(alertDefinitions routing.RouteRegister) {

View File

@ -0,0 +1,79 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strconv"
apimodels "github.com/grafana/alerting-api/pkg/api"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/grafana/grafana/pkg/util"
)
type TestingApiSrv struct {
*AlertingProxy
Cfg *setting.Cfg
DataService *tsdb.Service
DatasourceCache datasources.CacheService
log log.Logger
}
func (srv TestingApiSrv) RouteTestReceiverConfig(c *models.ReqContext, body apimodels.ExtendedReceiver) response.Response {
srv.log.Info("RouteTestReceiverConfig: ", "body", body)
return response.JSON(http.StatusOK, util.DynMap{"message": "success"})
}
func (srv TestingApiSrv) RouteTestRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response {
recipient := c.Params("Recipient")
if recipient == apimodels.GrafanaBackend.String() {
if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil {
return response.Error(http.StatusBadRequest, "unexpected payload", nil)
}
return conditionEval(c, *body.GrafanaManagedCondition, srv.DatasourceCache, srv.DataService, srv.Cfg)
}
if body.Type() != apimodels.LoTexRulerBackend {
return response.Error(http.StatusBadRequest, "unexpected payload", nil)
}
var path string
if datasourceID, err := strconv.ParseInt(recipient, 10, 64); err == nil {
ds, err := srv.DatasourceCache.GetDatasource(datasourceID, c.SignedInUser, c.SkipCache)
if err != nil {
return response.Error(http.StatusInternalServerError, "failed to get datasource", err)
}
switch ds.Type {
case "loki":
path = "loki/api/v1/query"
case "prometheus":
path = "api/v1/query"
default:
return response.Error(http.StatusBadRequest, fmt.Sprintf("unexpected recipient type %s", ds.Type), nil)
}
}
t := timeNow()
queryURL, err := url.Parse(path)
if err != nil {
return response.Error(http.StatusInternalServerError, "failed to parse url", err)
}
params := queryURL.Query()
params.Set("query", body.Expr)
params.Set("time", strconv.FormatInt(t.Unix(), 10))
queryURL.RawQuery = params.Encode()
return srv.withReq(
c,
http.MethodGet,
queryURL,
nil,
jsonExtractor(nil),
nil,
)
}

View File

@ -1,32 +0,0 @@
package api
import (
"net/http"
apimodels "github.com/grafana/alerting-api/pkg/api"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
)
type TestingApiMock struct {
log log.Logger
}
func (mock TestingApiMock) RouteTestReceiverConfig(c *models.ReqContext, body apimodels.ExtendedReceiver) response.Response {
mock.log.Info("RouteTestReceiverConfig: ", "body", body)
return response.JSON(http.StatusOK, util.DynMap{"message": "success"})
}
func (mock TestingApiMock) RouteTestRuleConfig(c *models.ReqContext, body apimodels.TestRulePayload) response.Response {
mock.log.Info("RouteTestRuleConfig: ", "body", body)
result := apimodels.TestRuleResponse{
GrafanaAlertInstances: apimodels.AlertInstancesResponse{
Instances: [][]byte{
[]byte("QVJST1cxAAD/////+AAAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAFAAAAACAAAAKAAAAAQAAACE////CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAKT///8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAAQAAABgAAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAAQAAAAEQAAAAAAAAGQAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAAAAAAAAAAA/////4gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAAAIAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAAA4AAAAAQAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAAAwABAAAACAEAAAAAAACQAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAABQAAAAAgAAACgAAAAEAAAAhP///wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAACk////CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAEAAAAYAAAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEAAAABEAAAAAAAABkAAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAMAAAAAAAAAAAAAAAEAAAAbmFtZQAAAAAAAAAABAAEAAQAAAAAAAAAAAAAACgBAABBUlJPVzE="),
},
},
}
return response.JSON(http.StatusOK, result)
}

View File

@ -23,7 +23,7 @@ type TestingApiService interface {
func (api *API) RegisterTestingApiEndpoints(srv TestingApiService) {
api.RouteRegister.Group("", func(group routing.RouteRegister) {
group.Post(toMacaronPath("/api/v1/receiver/test"), binding.Bind(apimodels.ExtendedReceiver{}), routing.Wrap(srv.RouteTestReceiverConfig))
group.Post(toMacaronPath("/api/v1/rule/test"), binding.Bind(apimodels.TestRulePayload{}), routing.Wrap(srv.RouteTestRuleConfig))
group.Post(toMacaronPath("/api/v1/receiver/test/{Recipient}"), binding.Bind(apimodels.ExtendedReceiver{}), routing.Wrap(srv.RouteTestReceiverConfig))
group.Post(toMacaronPath("/api/v1/rule/test/{Recipient}"), binding.Bind(apimodels.TestRulePayload{}), routing.Wrap(srv.RouteTestRuleConfig))
}, middleware.ReqSignedIn)
}

View File

@ -24,31 +24,7 @@ func (api *API) listAlertInstancesEndpoint(c *models.ReqContext) response.Respon
// conditionEvalEndpoint handles POST /api/alert-definitions/eval.
func (api *API) conditionEvalEndpoint(c *models.ReqContext, cmd ngmodels.EvalAlertConditionCommand) response.Response {
evalCond := ngmodels.Condition{
Condition: cmd.Condition,
OrgID: c.SignedInUser.OrgId,
Data: cmd.Data,
}
if err := api.validateCondition(evalCond, c.SignedInUser, c.SkipCache); err != nil {
return response.Error(400, "invalid condition", err)
}
now := cmd.Now
if now.IsZero() {
now = timeNow()
}
evaluator := eval.Evaluator{Cfg: api.Cfg}
evalResults, err := evaluator.ConditionEval(&evalCond, timeNow(), api.DataService)
if err != nil {
return response.Error(400, "Failed to evaluate conditions", err)
}
frame := evalResults.AsDataFrame()
return response.JSONStreaming(200, util.DynMap{
"instances": []*data.Frame{&frame},
})
return conditionEval(c, cmd, api.DatasourceCache, api.DataService, api.Cfg)
}
// alertDefinitionEvalEndpoint handles GET /api/alert-definitions/eval/:alertDefinitionUID.
@ -60,7 +36,7 @@ func (api *API) alertDefinitionEvalEndpoint(c *models.ReqContext) response.Respo
return response.Error(400, "Failed to load alert definition conditions", err)
}
if err := api.validateCondition(*condition, c.SignedInUser, c.SkipCache); err != nil {
if err := validateCondition(*condition, c.SignedInUser, c.SkipCache, api.DatasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
@ -118,7 +94,8 @@ func (api *API) updateAlertDefinitionEndpoint(c *models.ReqContext, cmd ngmodels
OrgID: c.SignedInUser.OrgId,
Data: cmd.Data,
}
if err := api.validateCondition(evalCond, c.SignedInUser, c.SkipCache); err != nil {
if err := validateCondition(evalCond, c.SignedInUser, c.SkipCache, api.DatasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
@ -138,7 +115,8 @@ func (api *API) createAlertDefinitionEndpoint(c *models.ReqContext, cmd ngmodels
OrgID: c.SignedInUser.OrgId,
Data: cmd.Data,
}
if err := api.validateCondition(evalCond, c.SignedInUser, c.SkipCache); err != nil {
if err := validateCondition(evalCond, c.SignedInUser, c.SkipCache, api.DatasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
@ -220,43 +198,6 @@ func (api *API) LoadAlertCondition(alertDefinitionUID string, orgID int64) (*ngm
}, nil
}
func (api *API) validateCondition(c ngmodels.Condition, user *models.SignedInUser, skipCache bool) error {
var refID string
if len(c.Data) == 0 {
return nil
}
for _, query := range c.Data {
if c.Condition == query.RefID {
refID = c.Condition
}
datasourceUID, err := query.GetDatasource()
if err != nil {
return err
}
isExpression, err := query.IsExpression()
if err != nil {
return err
}
if isExpression {
continue
}
_, err = api.DatasourceCache.GetDatasourceByUID(datasourceUID, user, skipCache)
if err != nil {
return fmt.Errorf("failed to get datasource: %s: %w", datasourceUID, err)
}
}
if refID == "" {
return fmt.Errorf("condition %s not found in any query or expression", c.Condition)
}
return nil
}
func (api *API) validateOrgAlertDefinition(c *models.ReqContext) {
uid := c.ParamsEscape(":alertDefinitionUID")

View File

@ -28,7 +28,8 @@ func (api *API) conditionEvalOldEndpoint(c *models.ReqContext) response.Response
if err != nil {
return response.Error(400, "Failed to translate alert conditions", err)
}
if err := api.validateCondition(*evalCond, c.SignedInUser, c.SkipCache); err != nil {
if err := validateCondition(*evalCond, c.SignedInUser, c.SkipCache, api.DatasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
//now := cmd.Now
@ -70,7 +71,8 @@ func (api *API) conditionEvalOldEndpointByID(c *models.ReqContext) response.Resp
if err != nil {
return response.Error(400, "Failed to translate alert conditions", err)
}
if err := api.validateCondition(*evalCond, c.SignedInUser, c.SkipCache); err != nil {
if err := validateCondition(*evalCond, c.SignedInUser, c.SkipCache, api.DatasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
//now := cmd.Now

View File

@ -0,0 +1,86 @@
@grafanaRecipient = grafana
@lokiDatasourceID = 32
@prometheusDatasourceID = 35
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{grafanaRecipient}}
content-type: application/json
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"model": {
"datasource": "__expr__",
"type":"math",
"expression":"1 < 2"
}
}
]
}
}
###
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{lokiDatasourceID}}
content-type: application/json
{
"expr": "rate({cluster=\"us-central1\", job=\"loki-prod/loki-canary\"}[1m]) > 0"
}
###
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{prometheusDatasourceID}}
content-type: application/json
{
"expr": "http_request_duration_microseconds > 1"
}
### loki recipient - empty payload
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{lokiDatasourceID}}
content-type: application/json
{}
### grafana recipient - empty payload
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{grafanaRecipient}}
content-type: application/json
{}
### loki recipient - grafana payload
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{lokiDatasourceID}}
content-type: application/json
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"model": {
"datasource": "__expr__",
"type":"math",
"expression":"1 < 2"
}
}
]
}
}}
### grafana recipient - lotex payload
POST http://admin:admin@localhost:3000/api/v1/rule/test/{{grafanaRecipient}}
content-type: application/json
{
"expr": "rate({cluster=\"us-central1\", job=\"loki-prod/loki-canary\"}[1m]) > 0"
}

View File

@ -12,10 +12,16 @@ import (
"strings"
apimodels "github.com/grafana/alerting-api/pkg/api"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/grafana/grafana/pkg/util"
"gopkg.in/macaron.v1"
"gopkg.in/yaml.v3"
)
@ -150,3 +156,68 @@ func jsonExtractor(v interface{}) func([]byte) (interface{}, error) {
func messageExtractor(b []byte) (interface{}, error) {
return map[string]string{"message": string(b)}, nil
}
func validateCondition(c ngmodels.Condition, user *models.SignedInUser, skipCache bool, datasourceCache datasources.CacheService) error {
var refID string
if len(c.Data) == 0 {
return nil
}
for _, query := range c.Data {
if c.Condition == query.RefID {
refID = c.Condition
}
datasourceUID, err := query.GetDatasource()
if err != nil {
return err
}
isExpression, err := query.IsExpression()
if err != nil {
return err
}
if isExpression {
continue
}
_, err = datasourceCache.GetDatasourceByUID(datasourceUID, user, skipCache)
if err != nil {
return fmt.Errorf("failed to get datasource: %s: %w", datasourceUID, err)
}
}
if refID == "" {
return fmt.Errorf("condition %s not found in any query or expression", c.Condition)
}
return nil
}
func conditionEval(c *models.ReqContext, cmd ngmodels.EvalAlertConditionCommand, datasourceCache datasources.CacheService, dataService *tsdb.Service, cfg *setting.Cfg) response.Response {
evalCond := ngmodels.Condition{
Condition: cmd.Condition,
OrgID: c.SignedInUser.OrgId,
Data: cmd.Data,
}
if err := validateCondition(evalCond, c.SignedInUser, c.SkipCache, datasourceCache); err != nil {
return response.Error(400, "invalid condition", err)
}
now := cmd.Now
if now.IsZero() {
now = timeNow()
}
evaluator := eval.Evaluator{Cfg: cfg}
evalResults, err := evaluator.ConditionEval(&evalCond, timeNow(), dataService)
if err != nil {
return response.Error(400, "Failed to evaluate conditions", err)
}
frame := evalResults.AsDataFrame()
return response.JSONStreaming(200, util.DynMap{
"instances": []*data.Frame{&frame},
})
}

View File

@ -165,7 +165,7 @@ func execute(ctx AlertExecCtx, c *models.Condition, now time.Time, dataService *
}
if len(result.Results) == 0 {
err = fmt.Errorf("no GEL results")
err = fmt.Errorf("no transformation results")
result.Error = err
return &result, err
}

View File

@ -0,0 +1,35 @@
package models
import (
"encoding/json"
"fmt"
"time"
)
// EvalAlertConditionCommand is the command for evaluating a condition
type EvalAlertConditionCommand struct {
Condition string `json:"condition"`
Data []AlertQuery `json:"data"`
Now time.Time `json:"now"`
}
func (cmd *EvalAlertConditionCommand) UnmarshalJSON(b []byte) error {
type plain EvalAlertConditionCommand
if err := json.Unmarshal(b, (*plain)(cmd)); err != nil {
return err
}
return cmd.validate()
}
func (cmd *EvalAlertConditionCommand) validate() error {
if cmd.Condition == "" {
return fmt.Errorf("missing condition")
}
if len(cmd.Data) == 0 {
return fmt.Errorf("missing data")
}
return nil
}

View File

@ -131,11 +131,3 @@ type UpdateAlertDefinitionPausedCommand struct {
ResultCount int64
}
// EvalAlertConditionCommand is the command for evaluating a condition
// Legacy model; It will be removed in v8
type EvalAlertConditionCommand struct {
Condition string `json:"condition"`
Data []AlertQuery `json:"data"`
Now time.Time `json:"now"`
}