Alerting: Repurpose rule testing endpoint to return potential alerts (#69755)

* Alerting: Repurpose rule testing endpoint to return potential alerts

This feature replaces the existing no-longer in-use grafana ruler testing API endpoint /api/v1/rule/test/grafana. The new endpoint returns a list of potential alerts created by the given alert rule, including built-in + interpolated labels and annotations.

The key priority of this endpoint is that it is intended to be as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to only Resolved / Firing and ready to be sent.

This means that the endpoint will, among other things:

- Attach static annotations and labels from the rule configuration to the alert instances.
- Attach dynamic annotations from the datasource to the alert instances.
- Attach built-in labels and annotations created by the Grafana Ruler (such as alertname and grafana_folder) to the alert instances.
- Interpolate templated annotations / labels and accept allowed template functions.
pull/69826/head
Matthew Jacobson 2 years ago committed by GitHub
parent 0c688190f7
commit ba3994d338
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      pkg/services/ngalert/api/api.go
  2. 82
      pkg/services/ngalert/api/api_testing.go
  3. 141
      pkg/services/ngalert/api/api_testing_test.go
  4. 2
      pkg/services/ngalert/api/generated_base_api_testing.go
  5. 2
      pkg/services/ngalert/api/testing_api.go
  6. 175
      pkg/services/ngalert/api/tooling/api.json
  7. 2
      pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go
  8. 39
      pkg/services/ngalert/api/tooling/definitions/testing.go
  9. 196
      pkg/services/ngalert/api/tooling/post.json
  10. 196
      pkg/services/ngalert/api/tooling/spec.json
  11. 2
      pkg/services/ngalert/notifier/templates.go
  12. 27
      pkg/services/ngalert/schedule/schedule.go
  13. 2
      pkg/services/ngalert/schedule/schedule_unit_test.go
  14. 21
      pkg/services/ngalert/state/compat.go
  15. 45
      pkg/services/ngalert/state/compat_test.go
  16. 16
      pkg/services/ngalert/state/state.go
  17. 231
      pkg/tests/api/alerting/api_alertmanager_test.go
  18. 408
      pkg/tests/api/alerting/api_testing_test.go
  19. 19
      pkg/tests/api/alerting/testing.go

@ -136,6 +136,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
cfg: &api.Cfg.UnifiedAlerting,
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory),
featureManager: api.FeatureManager,
appUrl: api.AppUrl,
}), m)
api.RegisterConfigurationApiEndpoints(NewConfiguration(
&ConfigSrv{

@ -8,7 +8,10 @@ import (
"strconv"
"time"
"github.com/benbjohnson/clock"
"github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
@ -16,10 +19,12 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/backtesting"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@ -33,46 +38,73 @@ type TestingApiSrv struct {
cfg *setting.UnifiedAlertingSettings
backtesting *backtesting.Engine
featureManager featuremgmt.FeatureToggles
appUrl *url.URL
}
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil {
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String()))
// RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be
// as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to
// only Resolved / Firing and ready to send.
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
rule, err := validateRuleNode(
&body.Rule,
body.RuleGroup,
srv.cfg.BaseInterval,
c.OrgID,
&folder.Folder{
OrgID: c.OrgID,
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 {
return ErrResp(http.StatusBadRequest, err, "")
}
queries := AlertQueriesFromApiAlertQueries(body.GrafanaManagedCondition.Data)
if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: queries}, func(evaluator accesscontrol.Evaluator) bool {
if !authorizeDatasourceAccessForRule(rule, func(evaluator accesscontrol.Evaluator) bool {
return accesscontrol.HasAccess(srv.accessControl, c)(evaluator)
}) {
return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization))
}
evalCond := ngmodels.Condition{
Condition: body.GrafanaManagedCondition.Condition,
Data: queries,
}
ctx := eval.NewContext(c.Req.Context(), c.SignedInUser)
conditionEval, err := srv.evaluator.Create(ctx, evalCond)
evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition())
if err != nil {
return ErrResp(http.StatusBadRequest, err, "invalid condition")
}
now := body.GrafanaManagedCondition.Now
if now.IsZero() {
now = timeNow()
return ErrResp(http.StatusBadRequest, err, "Failed to build evaluator for queries and expressions")
}
evalResults, err := conditionEval.Evaluate(c.Req.Context(), now)
now := time.Now()
results, err := evaluator.Evaluate(c.Req.Context(), now)
if err != nil {
return ErrResp(500, err, "Failed to evaluate the rule")
return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries")
}
cfg := state.ManagerCfg{
Metrics: nil,
ExternalURL: srv.appUrl,
InstanceStore: nil,
Images: &backtesting.NoopImageService{},
Clock: clock.New(),
Historian: nil,
}
manager := state.NewManager(cfg)
includeFolder := !srv.cfg.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel)
transitions := manager.ProcessEvalResults(
c.Req.Context(),
now,
rule,
results,
state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder),
)
alerts := make([]*amv2.PostableAlert, 0, len(transitions))
for _, alertState := range transitions {
alerts = append(alerts, state.StateToPostableAlert(alertState.State, srv.appUrl))
}
frame := evalResults.AsDataFrame()
return response.JSONStreaming(http.StatusOK, util.DynMap{
"instances": []*data.Frame{&frame},
})
return response.JSON(http.StatusOK, alerts)
}
func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response {

@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
@ -22,6 +23,107 @@ import (
"github.com/grafana/grafana/pkg/web"
)
func Test(t *testing.T) {
text := `{
"rule": {
"grafana_alert" : {
"condition": "C",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 600,
"to": 0
},
"queryType": "",
"datasourceUid": "PD8C576611E62080A",
"model": {
"refId": "A",
"hide": false,
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"scenarioId": "random_walk",
"seriesCount": 5,
"labels": "series=series-$seriesIndex"
}
},
{
"refId": "B",
"datasourceUid": "__expr__",
"queryType": "",
"model": {
"refId": "B",
"hide": false,
"type": "reduce",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"reducer": "last",
"expression": "A"
},
"relativeTimeRange": {
"from": 600,
"to": 0
}
},
{
"refId": "C",
"datasourceUid": "__expr__",
"queryType": "",
"model": {
"refId": "C",
"hide": false,
"type": "threshold",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [
0
],
"type": "gt"
}
}
],
"expression": "B"
},
"relativeTimeRange": {
"from": 600,
"to": 0
}
}
],
"no_data_state": "Alerting",
"title": "string"
},
"for": "0s",
"labels": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
},
"annotations": {
"additionalProp1": "string",
"additionalProp2": "string",
"additionalProp3": "string"
}
},
"folderUid": "test-uid",
"folderTitle": "test-folder"
}`
var conf definitions.PostableExtendedRuleNodeExtended
require.NoError(t, json.Unmarshal([]byte(text), &conf))
require.Equal(t, "test-folder", conf.NamespaceTitle)
}
func TestRouteTestGrafanaRuleConfig(t *testing.T) {
t.Run("when fine-grained access is enabled", func(t *testing.T) {
rc := &contextmodel.ReqContext{
@ -41,15 +143,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
})
srv := createTestingApiSrv(nil, ac, nil)
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}))
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
Expr: "",
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
Condition: data1.RefID,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
Now: time.Time{},
},
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
})
require.Equal(t, http.StatusUnauthorized, response.Status())
@ -59,8 +160,6 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
data1 := models.GenerateAlertQuery()
data2 := models.GenerateAlertQuery()
currentTime := time.Now()
ac := acMock.New().WithPermissions([]accesscontrol.Permission{
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)},
@ -77,20 +176,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
evalFactory := eval_mocks.NewEvaluatorFactory(evaluator)
srv := createTestingApiSrv(ds, ac, evalFactory)
srv := createTestingApiSrv(t, ds, ac, evalFactory)
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
Expr: "",
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
Condition: data1.RefID,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
Now: currentTime,
},
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
})
require.Equal(t, http.StatusOK, response.Status())
evaluator.AssertCalled(t, "Evaluate", mock.Anything, currentTime)
evaluator.AssertCalled(t, "Evaluate", mock.Anything, mock.Anything)
})
})
}
@ -153,7 +251,7 @@ func TestRouteEvalQueries(t *testing.T) {
}
evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil)
srv := createTestingApiSrv(ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
@ -167,7 +265,7 @@ func TestRouteEvalQueries(t *testing.T) {
})
}
func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
if ac == nil {
ac = acMock.New().WithDisabled()
}
@ -176,5 +274,6 @@ func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator
DatasourceCache: ds,
accessControl: ac,
evaluator: evaluator,
cfg: config(t),
}
}

@ -53,7 +53,7 @@ func (f *TestingApiHandler) RouteTestRuleConfig(ctx *contextmodel.ReqContext) re
}
func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *contextmodel.ReqContext) response.Response {
// Parse Request Body
conf := apimodels.TestRulePayload{}
conf := apimodels.PostableExtendedRuleNodeExtended{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}

@ -21,7 +21,7 @@ func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext
return f.svc.RouteTestRuleConfig(c, body, dsUID)
}
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
return f.svc.RouteTestGrafanaRuleConfig(c, body)
}

@ -543,6 +543,12 @@
},
"type": "array"
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
"DataLink": {
"description": "DataLink define what",
"properties": {
@ -889,7 +895,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -952,6 +958,56 @@
},
"type": "object"
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"format": "double",
"type": "number"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"items": {
"$ref": "#/definitions/Span"
},
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"format": "int32",
"type": "integer"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"format": "double",
"type": "number"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"format": "double",
"type": "number"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"format": "double",
"type": "number"
}
},
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"type": "object"
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"properties": {
@ -1553,12 +1609,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -1865,6 +1929,17 @@
},
"type": "object"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -2064,7 +2139,11 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"format": "int64",
"type": "integer"
@ -2232,6 +2311,29 @@
},
"type": "object"
},
"PostableExtendedRuleNodeExtended": {
"properties": {
"folderTitle": {
"example": "project_x",
"type": "string"
},
"folderUid": {
"example": "okrd3I0Vz",
"type": "string"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"example": "eval_group_1",
"type": "string"
}
},
"required": [
"rule"
],
"type": "object"
},
"PostableGrafanaReceiver": {
"properties": {
"disableResolveMessage": {
@ -2508,8 +2610,30 @@
},
"type": "array"
},
"ProxyConfig": {
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
},
"type": "object"
},
"PushoverConfig": {
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@ -2584,7 +2708,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -3007,6 +3131,9 @@
},
"Sample": {
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@ -3201,6 +3328,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"properties": {
"Length": {
"description": "Length of the span.",
"format": "uint32",
"type": "integer"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"format": "int32",
"type": "integer"
}
},
"title": "A Span defines a continuous sequence of buckets.",
"type": "object"
},
"Status": {
"format": "int64",
"type": "integer"
@ -3273,6 +3416,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
},
"title": "TelegramConfig configures notifications via Telegram.",
@ -3684,7 +3830,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
},
"title": "WebhookConfig configures notifications via a generic webhook.",
@ -3875,7 +4024,6 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@ -3931,13 +4079,13 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -4136,6 +4284,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@ -4173,7 +4322,6 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@ -5116,6 +5264,21 @@
"type": "array"
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": {
"description": "",
"schema": {

@ -12,6 +12,8 @@ import "github.com/grafana/grafana-plugin-sdk-go/data"
// Responses:
// 200: StateHistory
// swagger:response StateHistory
type StateHistory struct {
// in:body
Results *data.Frame `json:"results"`
}

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
@ -23,7 +24,9 @@ import (
// - application/json
//
// Responses:
// 200: TestRuleResponse
// 200: TestGrafanaRuleResponse
// 400: ValidationError
// 404: NotFound
// swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig
//
@ -71,7 +74,7 @@ type TestReceiverRequest struct {
Body ExtendedReceiver
}
// swagger:parameters RouteTestRuleConfig RouteTestRuleGrafanaConfig
// swagger:parameters RouteTestRuleConfig
type TestRuleRequest struct {
// in:body
Body TestRulePayload
@ -85,6 +88,38 @@ type TestRulePayload struct {
GrafanaManagedCondition *EvalAlertConditionCommand `json:"grafana_condition,omitempty"`
}
// swagger:response TestGrafanaRuleResponse
type TestGrafanaRuleResponse struct {
// in:body
Body []amv2.PostableAlert
}
// swagger:parameters RouteTestRuleGrafanaConfig
type TestGrafanaRuleRequest struct {
// in:body
Body PostableExtendedRuleNodeExtended
}
// swagger:model
type PostableExtendedRuleNodeExtended struct {
// required: true
Rule PostableExtendedRuleNode `json:"rule"`
// example: okrd3I0Vz
NamespaceUID string `json:"folderUid"`
// example: project_x
NamespaceTitle string `json:"folderTitle"`
// example: eval_group_1
RuleGroup string `json:"ruleGroup"`
}
func (n *PostableExtendedRuleNodeExtended) UnmarshalJSON(b []byte) error {
type plain PostableExtendedRuleNodeExtended
if err := json.Unmarshal(b, (*plain)(n)); err != nil {
return err
}
return nil
}
// swagger:parameters RouteEvalQueries
type EvalQueriesRequest struct {
// in:body

@ -543,6 +543,12 @@
},
"type": "array"
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
"DataLink": {
"description": "DataLink define what",
"properties": {
@ -889,7 +895,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -952,6 +958,56 @@
},
"type": "object"
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"format": "double",
"type": "number"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"items": {
"$ref": "#/definitions/Span"
},
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"format": "int32",
"type": "integer"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"format": "double",
"type": "number"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"format": "double",
"type": "number"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"format": "double",
"type": "number"
}
},
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"type": "object"
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"properties": {
@ -1553,12 +1609,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -1865,6 +1929,17 @@
},
"type": "object"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -2064,7 +2139,11 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"format": "int64",
"type": "integer"
@ -2232,6 +2311,29 @@
},
"type": "object"
},
"PostableExtendedRuleNodeExtended": {
"properties": {
"folderTitle": {
"example": "project_x",
"type": "string"
},
"folderUid": {
"example": "okrd3I0Vz",
"type": "string"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"example": "eval_group_1",
"type": "string"
}
},
"required": [
"rule"
],
"type": "object"
},
"PostableGrafanaReceiver": {
"properties": {
"disableResolveMessage": {
@ -2508,8 +2610,30 @@
},
"type": "array"
},
"ProxyConfig": {
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
},
"type": "object"
},
"PushoverConfig": {
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@ -2584,7 +2708,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -3007,6 +3131,9 @@
},
"Sample": {
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@ -3201,6 +3328,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"properties": {
"Length": {
"description": "Length of the span.",
"format": "uint32",
"type": "integer"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"format": "int32",
"type": "integer"
}
},
"title": "A Span defines a continuous sequence of buckets.",
"type": "object"
},
"Status": {
"format": "int64",
"type": "integer"
@ -3273,6 +3416,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
},
"title": "TelegramConfig configures notifications via Telegram.",
@ -3535,6 +3681,7 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -3570,7 +3717,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"Userinfo": {
@ -3684,7 +3831,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
},
"title": "WebhookConfig configures notifications via a generic webhook.",
@ -3747,7 +3897,6 @@
"type": "object"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3771,7 +3920,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -3876,6 +4024,7 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@ -3931,12 +4080,14 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -3985,7 +4136,6 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
@ -4136,6 +4286,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@ -4173,6 +4324,7 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@ -6926,7 +7078,7 @@
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/TestRulePayload"
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
}
}
],
@ -6935,9 +7087,18 @@
],
"responses": {
"200": {
"description": "TestRuleResponse",
"$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/TestRuleResponse"
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
},
@ -7022,6 +7183,21 @@
"type": "array"
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": {
"description": "",
"schema": {

@ -2683,15 +2683,24 @@
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/TestRulePayload"
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
}
}
],
"responses": {
"200": {
"description": "TestRuleResponse",
"$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/TestRuleResponse"
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
}
@ -3300,6 +3309,12 @@
"$ref": "#/definitions/EmbeddedContactPoint"
}
},
"CounterResetHint": {
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
"type": "integer",
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,"
},
"DataLink": {
"description": "DataLink define what",
"type": "object",
@ -3651,7 +3666,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -3712,6 +3727,56 @@
}
}
},
"FloatHistogram": {
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
"type": "object",
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
"properties": {
"Count": {
"description": "Total number of observations. Must be zero or positive.",
"type": "number",
"format": "double"
},
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"type": "array",
"items": {
"type": "number",
"format": "double"
}
},
"PositiveSpans": {
"description": "Spans for positive and negative buckets (see Span below).",
"type": "array",
"items": {
"$ref": "#/definitions/Span"
}
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"type": "integer",
"format": "int32"
},
"Sum": {
"description": "Sum of observations. This is also used as the stale marker.",
"type": "number",
"format": "double"
},
"ZeroCount": {
"description": "Observations falling into the zero bucket. Must be zero or positive.",
"type": "number",
"format": "double"
},
"ZeroThreshold": {
"description": "Width of the zero bucket.",
"type": "number",
"format": "double"
}
}
},
"Frame": {
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
"type": "object",
@ -4315,12 +4380,20 @@
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
"type": "boolean"
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": {
"$ref": "#/definitions/OAuth2"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -4628,6 +4701,17 @@
"type": "string"
}
},
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
},
@ -4825,9 +4909,13 @@
"type": "object"
},
"Point": {
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
"type": "object",
"title": "Point represents a single data point for a given timestamp.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": {
"type": "integer",
"format": "int64"
@ -4993,6 +5081,29 @@
}
}
},
"PostableExtendedRuleNodeExtended": {
"type": "object",
"required": [
"rule"
],
"properties": {
"folderTitle": {
"type": "string",
"example": "project_x"
},
"folderUid": {
"type": "string",
"example": "okrd3I0Vz"
},
"rule": {
"$ref": "#/definitions/PostableExtendedRuleNode"
},
"ruleGroup": {
"type": "string",
"example": "eval_group_1"
}
}
},
"PostableGrafanaReceiver": {
"type": "object",
"properties": {
@ -5269,9 +5380,31 @@
"$ref": "#/definitions/ProvisionedAlertRule"
}
},
"ProxyConfig": {
"type": "object",
"properties": {
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"proxy_connect_header": {
"$ref": "#/definitions/Header"
},
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": {
"$ref": "#/definitions/URL"
}
}
},
"PushoverConfig": {
"type": "object",
"properties": {
"device": {
"type": "string"
},
"expire": {
"type": "string"
},
@ -5347,7 +5480,7 @@
"type": "string"
},
"displayNameFromDS": {
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
"type": "string"
},
"filterable": {
@ -5770,6 +5903,9 @@
"type": "object",
"title": "Sample is a single sample belonging to a metric.",
"properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": {
"$ref": "#/definitions/Labels"
},
@ -5962,6 +6098,22 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"Span": {
"type": "object",
"title": "A Span defines a continuous sequence of buckets.",
"properties": {
"Length": {
"description": "Length of the span.",
"type": "integer",
"format": "uint32"
},
"Offset": {
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
"type": "integer",
"format": "int32"
}
}
},
"Status": {
"type": "integer",
"format": "int64"
@ -6036,6 +6188,9 @@
},
"token": {
"$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
}
}
},
@ -6296,8 +6451,9 @@
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -6447,7 +6603,10 @@
"type": "boolean"
},
"url": {
"$ref": "#/definitions/URL"
"$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
}
}
},
@ -6508,7 +6667,6 @@
}
},
"alertGroup": {
"description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@ -6533,7 +6691,6 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@ -6639,6 +6796,7 @@
}
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@ -6695,6 +6853,7 @@
"$ref": "#/definitions/gettableAlert"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"$ref": "#/definitions/gettableAlert"
@ -6702,6 +6861,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@ -6751,7 +6911,6 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
@ -6904,6 +7063,7 @@
}
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@ -6942,6 +7102,7 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"active",
@ -7066,6 +7227,21 @@
}
}
},
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/postableAlert"
}
}
},
"receiversResponse": {
"description": "",
"schema": {

@ -41,7 +41,7 @@ func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTempla
})
}
// addDefaultLabelsAndAnnotations is a slimmed down version of schedule.stateToPostableAlert and schedule.getRuleExtraLabels using default values.
// addDefaultLabelsAndAnnotations is a slimmed down version of state.StateToPostableAlert and state.GetRuleExtraLabels using default values.
func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) {
if alert.Labels == nil {
alert.Labels = make(map[string]string)

@ -8,9 +8,7 @@ import (
"time"
"github.com/benbjohnson/clock"
alertingModels "github.com/grafana/alerting/models"
"github.com/hashicorp/go-multierror"
prometheusModel "github.com/prometheus/common/model"
"go.opentelemetry.io/otel/attribute"
"golang.org/x/sync/errgroup"
@ -355,7 +353,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID)
notify := func(states []state.StateTransition) {
expiredAlerts := FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
expiredAlerts := state.FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
if len(expiredAlerts.PostableAlerts) > 0 {
sch.alertsSender.Send(key, expiredAlerts)
}
@ -425,8 +423,14 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
logger.Debug("Skip updating the state because the context has been cancelled")
return
}
processedStates := sch.stateManager.ProcessEvalResults(ctx, e.scheduledAt, e.rule, results, sch.getRuleExtraLabels(e))
alerts := FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
processedStates := sch.stateManager.ProcessEvalResults(
ctx,
e.scheduledAt,
e.rule,
results,
state.GetRuleExtraLabels(e.rule, e.folderTitle, !sch.disableGrafanaFolder),
)
alerts := state.FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
span.AddEvents(
[]string{"message", "state_transitions", "alerts_to_send"},
[]tracing.EventValue{
@ -558,19 +562,6 @@ func (sch *schedule) stopApplied(alertDefKey ngmodels.AlertRuleKey) {
sch.stopAppliedFunc(alertDefKey)
}
func (sch *schedule) getRuleExtraLabels(evalCtx *evaluation) map[string]string {
extraLabels := make(map[string]string, 4)
extraLabels[alertingModels.NamespaceUIDLabel] = evalCtx.rule.NamespaceUID
extraLabels[prometheusModel.AlertNameLabel] = evalCtx.rule.Title
extraLabels[alertingModels.RuleUIDLabel] = evalCtx.rule.UID
if !sch.disableGrafanaFolder {
extraLabels[ngmodels.FolderTitleLabel] = evalCtx.folderTitle
}
return extraLabels
}
func SchedulerUserFor(orgID int64) *user.SignedInUser {
return &user.SignedInUser{
UserID: -1,

@ -676,7 +676,7 @@ func TestSchedule_ruleRoutine(t *testing.T) {
args, ok := sender.Calls[0].Arguments[1].(definitions.PostableAlerts)
require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[1]))
assert.Len(t, args.PostableAlerts, 1)
assert.Equal(t, ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
})
})

@ -1,4 +1,4 @@
package schedule
package state
import (
"encoding/json"
@ -18,7 +18,6 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
)
const (
@ -28,13 +27,13 @@ const (
Rulename = "rulename"
)
// stateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
// StateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
// - if state has at least one result, a new label '__value_string__' is added to the label set
// - the alert's GeneratorURL is constructed to point to the alert detail view
// - if evaluation state is either NoData or Error, the resulting set of labels is changed:
// - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName
// - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName
func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.PostableAlert {
func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAlert {
nL := alertState.Labels.Copy()
nA := data.Labels(alertState.Annotations).Copy()
@ -95,7 +94,7 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post
// It effectively replaces the legacy behavior of "Keep Last State" by separating the regular alerting flow from the no data scenario into a separate alerts.
// The Alert is defined as:
// { alertname=DatasourceNoData rulename=original_alertname } + { rule labelset } + { rule annotations }
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name
}
@ -114,7 +113,7 @@ func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.
// errorAlert is a special alert sent when evaluation of an alert rule failed due to an error. Like noDataAlert, it
// replaces the old behaviour of "Keep Last State" creating a separate alert called DatasourceError.
func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
func errorAlert(labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name
}
@ -131,16 +130,16 @@ func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr
}
}
func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts {
func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts {
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
var sentAlerts []*state.State
var sentAlerts []*State
ts := time.Now()
for _, alertState := range firingStates {
if !alertState.NeedsSending(stateManager.ResendDelay) {
continue
}
alert := stateToPostableAlert(alertState.State, appURL)
alert := StateToPostableAlert(alertState.State, appURL)
alerts.PostableAlerts = append(alerts.PostableAlerts, *alert)
if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager
continue
@ -154,14 +153,14 @@ func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, s
// FromAlertsStateToStoppedAlert selects only transitions from firing states (states eval.Alerting, eval.NoData, eval.Error)
// and converts them to models.PostableAlert with EndsAt set to time.Now
func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
ts := clock.Now()
for _, transition := range firingStates {
if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending {
continue
}
postableAlert := stateToPostableAlert(transition.State, appURL)
postableAlert := StateToPostableAlert(transition.State, appURL)
postableAlert.EndsAt = strfmt.DateTime(ts)
alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert)
}

@ -1,4 +1,4 @@
package schedule
package state
import (
"fmt"
@ -16,11 +16,10 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/util"
)
func Test_stateToPostableAlert(t *testing.T) {
func Test_StateToPostableAlert(t *testing.T) {
appURL := &url.URL{
Scheme: "http:",
Host: fmt.Sprintf("host-%d", rand.Int()),
@ -59,7 +58,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("to alert rule", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
u := *appURL
u.Path = u.Path + "/alerting/grafana/" + alertState.AlertRuleUID + "/view"
require.Equal(t, u.String(), result.Alert.GeneratorURL.String())
@ -68,25 +67,25 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("app URL as is if rule UID is not specified", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = ""
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
delete(alertState.Labels, alertingModels.RuleUIDLabel)
result = stateToPostableAlert(alertState, appURL)
result = StateToPostableAlert(alertState, appURL)
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
})
t.Run("empty string if app URL is not provided", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
result := stateToPostableAlert(alertState, nil)
result := StateToPostableAlert(alertState, nil)
require.Equal(t, "", result.Alert.GeneratorURL.String())
})
})
t.Run("Start and End timestamps should be the same", func(t *testing.T) {
alertState := randomState(tc.state)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, strfmt.DateTime(alertState.StartsAt), result.StartsAt)
require.Equal(t, strfmt.DateTime(alertState.EndsAt), result.EndsAt)
})
@ -94,7 +93,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("should copy annotations", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Annotations = randomMapOfStrings()
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, models.LabelSet(alertState.Annotations), result.Annotations)
t.Run("add __value_string__ if it has results", func(t *testing.T) {
@ -103,7 +102,7 @@ func Test_stateToPostableAlert(t *testing.T) {
expectedValueString := util.GenerateShortUID()
alertState.LastEvaluationString = expectedValueString
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Annotations)+1)
for k, v := range alertState.Annotations {
@ -115,7 +114,7 @@ func Test_stateToPostableAlert(t *testing.T) {
// even overwrites
alertState.Annotations["__value_string__"] = util.GenerateShortUID()
result = stateToPostableAlert(alertState, appURL)
result = StateToPostableAlert(alertState, appURL)
require.Equal(t, expected, result.Annotations)
})
@ -124,7 +123,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Annotations = randomMapOfStrings()
alertState.Image = &ngModels.Image{Token: "test_token"}
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Annotations)+1)
for k, v := range alertState.Annotations {
@ -139,7 +138,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("should add state reason annotation if not empty", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.StateReason = "TEST_STATE_REASON"
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, alertState.StateReason, result.Annotations[ngModels.StateReasonAnnotation])
})
@ -151,7 +150,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertName := util.GenerateShortUID()
alertState.Labels[model.AlertNameLabel] = alertName
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Labels)+1)
for k, v := range alertState.Labels {
@ -167,7 +166,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Labels = randomMapOfStrings()
delete(alertState.Labels, model.AlertNameLabel)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, NoDataAlertName, result.Labels[model.AlertNameLabel])
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
@ -180,7 +179,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertName := util.GenerateShortUID()
alertState.Labels[model.AlertNameLabel] = alertName
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
expected := make(models.LabelSet, len(alertState.Labels)+1)
for k, v := range alertState.Labels {
@ -196,7 +195,7 @@ func Test_stateToPostableAlert(t *testing.T) {
alertState.Labels = randomMapOfStrings()
delete(alertState.Labels, model.AlertNameLabel)
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, ErrorAlertName, result.Labels[model.AlertNameLabel])
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
@ -206,7 +205,7 @@ func Test_stateToPostableAlert(t *testing.T) {
t.Run("should copy labels as is", func(t *testing.T) {
alertState := randomState(tc.state)
alertState.Labels = randomMapOfStrings()
result := stateToPostableAlert(alertState, appURL)
result := StateToPostableAlert(alertState, appURL)
require.Equal(t, models.LabelSet(alertState.Labels), result.Labels)
})
}
@ -222,10 +221,10 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
}
evalStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.Error, eval.NoData}
states := make([]state.StateTransition, 0, len(evalStates)*len(evalStates))
states := make([]StateTransition, 0, len(evalStates)*len(evalStates))
for _, to := range evalStates {
for _, from := range evalStates {
states = append(states, state.StateTransition{
states = append(states, StateTransition{
State: randomState(to),
PreviousState: from,
})
@ -240,7 +239,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) {
continue
}
alert := stateToPostableAlert(s.State, appURL)
alert := StateToPostableAlert(s.State, appURL)
alert.EndsAt = strfmt.DateTime(clk.Now())
expected = append(expected, *alert)
}
@ -271,8 +270,8 @@ func randomTimeInPast() time.Time {
return time.Now().Add(-randomDuration())
}
func randomState(evalState eval.State) *state.State {
return &state.State{
func randomState(evalState eval.State) *State {
return &State{
State: evalState,
AlertRuleUID: util.GenerateShortUID(),
StartsAt: time.Now(),

@ -8,7 +8,9 @@ import (
"strings"
"time"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data"
prometheusModel "github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/log"
@ -397,3 +399,17 @@ func FormatStateAndReason(state eval.State, reason string) string {
}
return s
}
// GetRuleExtraLabels returns a map of built-in labels that should be added to an alert before it is sent to the Alertmanager or its state is cached.
func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string {
extraLabels := make(map[string]string, 4)
extraLabels[alertingModels.NamespaceUIDLabel] = rule.NamespaceUID
extraLabels[prometheusModel.AlertNameLabel] = rule.Title
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
if includeFolder {
extraLabels[models.FolderTitleLabel] = folderTitle
}
return extraLabels
}

@ -2119,237 +2119,6 @@ func TestIntegrationEval(t *testing.T) {
expectedStatusCode func() int
expectedResponse func() string
expectedMessage func() string
}{
{
desc: "alerting condition",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid":"__expr__",
"model": {
"type":"math",
"expression":"1 < 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedMessage: func() string { return "" },
expectedStatusCode: func() int { return http.StatusOK },
expectedResponse: func() string {
return `{
"instances": [
{
"schema": {
"name": "evaluation results",
"fields": [
{
"name": "State",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"Alerting"
],
[
"[ var='A' labels={} value=1 ]"
]
]
}
}
]
}`
},
},
{
desc: "normal condition",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"type":"math",
"expression":"1 > 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedMessage: func() string { return "" },
expectedStatusCode: func() int { return http.StatusOK },
expectedResponse: func() string {
return `{
"instances": [
{
"schema": {
"name": "evaluation results",
"fields": [
{
"name": "State",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "Info",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"Normal"
],
[
"[ var='A' labels={} value=0 ]"
]
]
}
}
]
}`
},
},
{
desc: "condition not found in any query or expression",
payload: `
{
"grafana_condition": {
"condition": "B",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"type":"math",
"expression":"1 > 2"
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedStatusCode: func() int { return http.StatusBadRequest },
expectedMessage: func() string {
return "invalid condition: condition B does not exist, must be one of [A]"
},
expectedResponse: func() string { return "" },
},
{
desc: "unknown query datasource",
payload: `
{
"grafana_condition": {
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "unknown",
"model": {
}
}
],
"now": "2021-04-11T14:38:14Z"
}
}
`,
expectedStatusCode: func() int {
if setting.IsEnterprise {
return http.StatusUnauthorized
}
return http.StatusBadRequest
},
expectedMessage: func() string {
if setting.IsEnterprise {
return "user is not authorized to query one or many data sources used by the rule"
}
return "invalid condition: failed to build query 'A': data source not found"
},
expectedResponse: func() string { return "" },
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
u := fmt.Sprintf("http://grafana:password@%s/api/v1/rule/test/grafana", grafanaListedAddr)
r := strings.NewReader(tc.payload)
// nolint:gosec
resp, err := http.Post(u, "application/json", r)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
res := Response{}
err = json.Unmarshal(b, &res)
require.NoError(t, err)
assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode)
if tc.expectedResponse() != "" {
require.JSONEq(t, tc.expectedResponse(), string(b))
}
if tc.expectedMessage() != "" {
assert.Equal(t, tc.expectedMessage(), res.Message)
}
})
}
// test eval queries and expressions
testCases = []struct {
desc string
payload string
expectedStatusCode func() int
expectedResponse func() string
expectedMessage func() string
}{
{
desc: "alerting condition",

@ -0,0 +1,408 @@
package alerting
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
alertingModels "github.com/grafana/alerting/models"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/common/model"
"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"
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"
)
const (
TESTDATA_UID = "testdata"
)
func TestGrafanaRuleConfig(t *testing.T) {
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{},
EnableLog: false,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
userId := createUser(t, env.SQLStore, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
})
apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
dsCmd := &datasources.AddDataSourceCommand{
Name: "TestDatasource",
Type: "testdata",
Access: datasources.DS_ACCESS_PROXY,
UID: TESTDATA_UID,
UserID: userId,
OrgID: 1,
}
_, err := env.Server.HTTPServer.DataSourcesService.AddDataSource(context.Background(), dsCmd)
require.NoError(t, err)
dynamicLabels := []string{"GA", "FL", "AL", "AZ"}
dynamicLabelsJson, _ := json.Marshal(&dynamicLabels)
testdataQueryModel := json.RawMessage(fmt.Sprintf(`{
"refId": "A",
"hide": false,
"scenarioId": "usa",
"usa": {
"mode": "timeseries",
"period": "1m",
"states": %s,
"fields": [
"baz"
]
}
}`, string(dynamicLabelsJson)))
genRule := func(ruleGen func() apimodels.PostableExtendedRuleNode) apimodels.PostableExtendedRuleNodeExtended {
return apimodels.PostableExtendedRuleNodeExtended{
Rule: ruleGen(),
NamespaceUID: "NamespaceUID",
NamespaceTitle: "NamespaceTitle",
}
}
t.Run("valid rule should accept request", func(t *testing.T) {
status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen()))
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
})
t.Run("valid rule should return alerts in response", func(t *testing.T) {
status, body := apiCli.SubmitRuleForTesting(t, genRule(alertRuleGen()))
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 1)
})
t.Run("valid rule should return static annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"foo": "bar",
"foo2": "bar2",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "bar", alert.Annotations["foo"])
require.Equal(t, "bar2", alert.Annotations["foo2"])
}
})
t.Run("valid rule should return static labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"foo": "bar",
"foo2": "bar2",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "bar", alert.Labels["foo"])
require.Equal(t, "bar2", alert.Labels["foo2"])
}
})
t.Run("valid rule should return interpolated annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"value": "{{ $value }}",
"values.B": "{{ $values.B }}",
"values.C": "{{ $values.C }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.NotEmpty(t, alert.Annotations["values.B"])
require.NotEmpty(t, alert.Annotations["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Annotations["values.C"])
require.Contains(t, alert.Annotations["value"], valueB)
require.Contains(t, alert.Annotations["value"], valueC)
}
})
t.Run("valid rule should return interpolated labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"value": "{{ $value }}",
"values.B": "{{ $values.B }}",
"values.C": "{{ $values.C }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.NotEmpty(t, alert.Labels["values.B"])
require.NotEmpty(t, alert.Labels["values.C"])
valueB := fmt.Sprintf("[ var='B' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.B"])
valueC := fmt.Sprintf("[ var='C' labels={state=%s} value=%s ]", dynamicLabels[i], alert.Labels["values.C"])
require.Contains(t, alert.Labels["value"], valueB)
require.Contains(t, alert.Labels["value"], valueC)
}
})
t.Run("valid rule should use functions with annotations", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Annotations = map[string]string{
"externalURL": "{{ externalURL }}",
"humanize": "{{ humanize 1000.0 }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "http://localhost:3000/", alert.Annotations["externalURL"])
require.Equal(t, "1k", alert.Annotations["humanize"])
}
})
t.Run("valid rule should use functions with labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
rule.Rule.Labels = map[string]string{
"externalURL": "{{ externalURL }}",
"humanize": "{{ humanize 1000.0 }}",
}
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, "http://localhost:3000/", alert.Labels["externalURL"])
require.Equal(t, "1k", alert.Labels["humanize"])
}
})
t.Run("valid rule should return dynamic labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for i, alert := range result {
require.Equal(t, dynamicLabels[i], alert.Labels["state"])
}
})
t.Run("valid rule should return built-in labels", func(t *testing.T) {
rule := genRule(testdataRule(testdataQueryModel, nil, nil))
status, body := apiCli.SubmitRuleForTesting(t, rule)
require.Equal(t, http.StatusOK, status)
var result []amv2.PostableAlert
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
require.Len(t, result, 4)
for _, alert := range result {
require.Equal(t, rule.Rule.GrafanaManagedAlert.Title, alert.Labels[model.AlertNameLabel])
require.Equal(t, rule.NamespaceUID, alert.Labels[alertingModels.NamespaceUIDLabel])
require.Equal(t, rule.NamespaceTitle, alert.Labels[ngmodels.FolderTitleLabel])
}
})
t.Run("invalid rule should reject request", func(t *testing.T) {
req := genRule(alertRuleGen())
req.Rule = apimodels.PostableExtendedRuleNode{}
status, _ := apiCli.SubmitRuleForTesting(t, req)
require.Equal(t, http.StatusBadRequest, status)
})
t.Run("authentication permissions", func(t *testing.T) {
if !setting.IsEnterprise {
t.Skip("Enterprise-only test")
}
testUserId := createUser(t, env.SQLStore, user.CreateUserCommand{
DefaultOrgRole: "DOESNOTEXIST", // Needed so that the SignedInUser has OrgId=1. Otherwise, datasource will not be found.
Password: "test",
Login: "test",
})
testUserApiCli := newAlertingApiClient(grafanaListedAddr, "test", "test")
t.Run("fail if can't read rules", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Contains(t, body, accesscontrol.ActionAlertingRuleRead)
require.Equalf(t, http.StatusForbidden, status, "Response: %s", body)
})
// access control permissions store
permissionsStore := resourcepermissions.NewStore(env.SQLStore)
_, err := permissionsStore.SetUserResourcePermission(context.Background(),
accesscontrol.GlobalOrgID,
accesscontrol.User{ID: testUserId},
resourcepermissions.SetResourcePermissionCommand{
Actions: []string{
accesscontrol.ActionAlertingRuleRead,
},
Resource: "folders",
ResourceID: "*",
ResourceAttribute: "uid",
}, nil)
require.NoError(t, err)
testUserApiCli.ReloadCachedPermissions(t)
t.Run("fail if can't query data sources", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Contains(t, body, "user is not authorized to query one or many data sources used by the rule")
require.Equalf(t, http.StatusUnauthorized, status, "Response: %s", body)
})
_, err = permissionsStore.SetUserResourcePermission(context.Background(),
accesscontrol.GlobalOrgID,
accesscontrol.User{ID: testUserId},
resourcepermissions.SetResourcePermissionCommand{
Actions: []string{
datasources.ActionQuery,
},
Resource: "datasources",
ResourceID: TESTDATA_UID,
ResourceAttribute: "uid",
}, nil)
require.NoError(t, err)
testUserApiCli.ReloadCachedPermissions(t)
t.Run("succeed if can query data sources", func(t *testing.T) {
status, body := testUserApiCli.SubmitRuleForTesting(t, genRule(testdataRule(testdataQueryModel, nil, nil)))
require.Equalf(t, http.StatusOK, status, "Response: %s", body)
})
})
}
func testdataRule(queryModel json.RawMessage, labels map[string]string, annotations map[string]string) func() apimodels.PostableExtendedRuleNode {
return func() apimodels.PostableExtendedRuleNode {
forDuration := model.Duration(10 * time.Second)
return apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{
For: &forDuration,
Labels: labels,
Annotations: annotations,
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: fmt.Sprintf("rule-%s", util.GenerateShortUID()),
Condition: "C",
Data: []apimodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 600, To: 0},
DatasourceUID: TESTDATA_UID,
Model: queryModel,
},
{ // Simple reduce last A.
RefID: "B",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{
"refId": "B",
"hide": false,
"type": "reduce",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"B"
]
},
"reducer": {
"params": [],
"type": "last"
}
}
],
"reducer": "last",
"expression": "A"
}`),
},
{ // Threshold B > 0.
RefID: "C",
RelativeTimeRange: apimodels.RelativeTimeRange{From: 0, To: 0},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{
"refId": "C",
"hide": false,
"type": "threshold",
"datasource": {
"uid": "__expr__",
"type": "__expr__"
},
"conditions": [
{
"type": "query",
"evaluator": {
"params": [
0
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"C"
]
},
"reducer": {
"params": [],
"type": "last"
}
}
],
"expression": "B"
}`),
},
},
},
}
}
}

@ -334,3 +334,22 @@ func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.Backt
require.NoError(t, err)
return resp.StatusCode, string(b)
}
func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableExtendedRuleNodeExtended) (int, string) {
t.Helper()
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(config)
require.NoError(t, err)
u := fmt.Sprintf("%s/api/v1/rule/test/grafana", 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)
return resp.StatusCode, string(b)
}

Loading…
Cancel
Save