diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 08fedec5108..2f7b106b591 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.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{ diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index d9ad80b0681..9acbc01d07b 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -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") + return ErrResp(http.StatusBadRequest, err, "Failed to build evaluator for queries and expressions") } - now := body.GrafanaManagedCondition.Now - if now.IsZero() { - now = timeNow() - } - - 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") } - frame := evalResults.AsDataFrame() - return response.JSONStreaming(http.StatusOK, util.DynMap{ - "instances": []*data.Frame{&frame}, - }) + 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)) + } + + return response.JSON(http.StatusOK, alerts) } func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response { diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go index 5fd98200e79..dde667a7690 100644 --- a/pkg/services/ngalert/api/api_testing_test.go +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -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), } } diff --git a/pkg/services/ngalert/api/generated_base_api_testing.go b/pkg/services/ngalert/api/generated_base_api_testing.go index 342d0460b5f..1c6d5427501 100644 --- a/pkg/services/ngalert/api/generated_base_api_testing.go +++ b/pkg/services/ngalert/api/generated_base_api_testing.go @@ -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) } diff --git a/pkg/services/ngalert/api/testing_api.go b/pkg/services/ngalert/api/testing_api.go index 23da0884de3..70b54e12f0c 100644 --- a/pkg/services/ngalert/api/testing_api.go +++ b/pkg/services/ngalert/api/testing_api.go @@ -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) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index bdb1ac22de1..0e7557841cd 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -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": { diff --git a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go index 4c31b416cf3..9d365659cc8 100644 --- a/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go +++ b/pkg/services/ngalert/api/tooling/definitions/ruler_state_history.go @@ -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"` } diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 9824b29eda5..e5c3265dbab 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -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 diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index ef1087733a9..478d010a74c 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -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": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 5e2113b840d..400387ec389 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -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": { diff --git a/pkg/services/ngalert/notifier/templates.go b/pkg/services/ngalert/notifier/templates.go index 0f34a7e60bd..ccd8ecdb7df 100644 --- a/pkg/services/ngalert/notifier/templates.go +++ b/pkg/services/ngalert/notifier/templates.go @@ -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) diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 16523eb8754..69cbbd97128 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -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, diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 1e003cc4d93..53280768f91 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -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]) }) }) diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/state/compat.go similarity index 87% rename from pkg/services/ngalert/schedule/compat.go rename to pkg/services/ngalert/state/compat.go index 14c87ae663a..70d97e474d8 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/state/compat.go @@ -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) } diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/state/compat_test.go similarity index 87% rename from pkg/services/ngalert/schedule/compat_test.go rename to pkg/services/ngalert/state/compat_test.go index 7e61fd3d055..a33551cc27f 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/state/compat_test.go @@ -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(), diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 88d62492c93..7b40fac3b96 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -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 +} diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index f4091febf43..5602c9d4670 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -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", diff --git a/pkg/tests/api/alerting/api_testing_test.go b/pkg/tests/api/alerting/api_testing_test.go new file mode 100644 index 00000000000..1a0d85bfd29 --- /dev/null +++ b/pkg/tests/api/alerting/api_testing_test.go @@ -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" + }`), + }, + }, + }, + } + } +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index ee759178674..a06053eac08 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -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) +}