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.
This commit is contained in:
Matthew Jacobson
2023-06-08 18:59:54 -04:00
committed by GitHub
parent 0c688190f7
commit ba3994d338
19 changed files with 1246 additions and 361 deletions

View File

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

View File

@@ -8,7 +8,10 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/benbjohnson/clock"
"github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data" "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/api/response"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
@@ -16,10 +19,12 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt" "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" 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/backtesting"
"github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" 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/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@@ -33,46 +38,73 @@ type TestingApiSrv struct {
cfg *setting.UnifiedAlertingSettings cfg *setting.UnifiedAlertingSettings
backtesting *backtesting.Engine backtesting *backtesting.Engine
featureManager featuremgmt.FeatureToggles featureManager featuremgmt.FeatureToggles
appUrl *url.URL
} }
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response { // RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be
if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil { // as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String())) // 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(rule, func(evaluator accesscontrol.Evaluator) bool {
if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: queries}, func(evaluator accesscontrol.Evaluator) bool {
return accesscontrol.HasAccess(srv.accessControl, c)(evaluator) return accesscontrol.HasAccess(srv.accessControl, c)(evaluator)
}) { }) {
return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization)) return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization))
} }
evalCond := ngmodels.Condition{ evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition())
Condition: body.GrafanaManagedCondition.Condition,
Data: queries,
}
ctx := eval.NewContext(c.Req.Context(), c.SignedInUser)
conditionEval, err := srv.evaluator.Create(ctx, evalCond)
if err != nil { 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 now := time.Now()
if now.IsZero() { results, err := evaluator.Evaluate(c.Req.Context(), now)
now = timeNow()
}
evalResults, err := conditionEval.Evaluate(c.Req.Context(), now)
if err != nil { if err != nil {
return ErrResp(500, err, "Failed to evaluate the rule") return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries")
} }
frame := evalResults.AsDataFrame() cfg := state.ManagerCfg{
return response.JSONStreaming(http.StatusOK, util.DynMap{ Metrics: nil,
"instances": []*data.Frame{&frame}, 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 { func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response {

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@@ -22,6 +23,107 @@ import (
"github.com/grafana/grafana/pkg/web" "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) { func TestRouteTestGrafanaRuleConfig(t *testing.T) {
t.Run("when fine-grained access is enabled", func(t *testing.T) { t.Run("when fine-grained access is enabled", func(t *testing.T) {
rc := &contextmodel.ReqContext{ rc := &contextmodel.ReqContext{
@@ -41,15 +143,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, {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{ rule := validRule()
Expr: "", rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{ response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Condition: data1.RefID, Rule: rule,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), NamespaceUID: "test-folder",
Now: time.Time{}, NamespaceTitle: "test-folder",
},
}) })
require.Equal(t, http.StatusUnauthorized, response.Status()) require.Equal(t, http.StatusUnauthorized, response.Status())
@@ -59,8 +160,6 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
data1 := models.GenerateAlertQuery() data1 := models.GenerateAlertQuery()
data2 := models.GenerateAlertQuery() data2 := models.GenerateAlertQuery()
currentTime := time.Now()
ac := acMock.New().WithPermissions([]accesscontrol.Permission{ ac := acMock.New().WithPermissions([]accesscontrol.Permission{
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)}, {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)},
@@ -77,20 +176,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
evalFactory := eval_mocks.NewEvaluatorFactory(evaluator) evalFactory := eval_mocks.NewEvaluatorFactory(evaluator)
srv := createTestingApiSrv(ds, ac, evalFactory) srv := createTestingApiSrv(t, ds, ac, evalFactory)
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{ rule := validRule()
Expr: "", rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{ response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Condition: data1.RefID, Rule: rule,
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), NamespaceUID: "test-folder",
Now: currentTime, NamespaceTitle: "test-folder",
},
}) })
require.Equal(t, http.StatusOK, response.Status()) 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) 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{ response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}), 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 { if ac == nil {
ac = acMock.New().WithDisabled() ac = acMock.New().WithDisabled()
} }
@@ -176,5 +274,6 @@ func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator
DatasourceCache: ds, DatasourceCache: ds,
accessControl: ac, accessControl: ac,
evaluator: evaluator, evaluator: evaluator,
cfg: config(t),
} }
} }

View File

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

View File

@@ -21,7 +21,7 @@ func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext
return f.svc.RouteTestRuleConfig(c, body, dsUID) 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) return f.svc.RouteTestGrafanaRuleConfig(c, body)
} }

View File

@@ -543,6 +543,12 @@
}, },
"type": "array" "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": { "DataLink": {
"description": "DataLink define what", "description": "DataLink define what",
"properties": { "properties": {
@@ -889,7 +895,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "filterable": {
@@ -952,6 +958,56 @@
}, },
"type": "object" "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": { "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.", "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": { "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.", "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" "type": "boolean"
}, },
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": { "oauth2": {
"$ref": "#/definitions/OAuth2" "$ref": "#/definitions/OAuth2"
}, },
"proxy_connect_header": { "proxy_connect_header": {
"$ref": "#/definitions/Header" "$ref": "#/definitions/Header"
}, },
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -1865,6 +1929,17 @@
}, },
"type": "object" "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": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -2064,7 +2139,11 @@
"type": "object" "type": "object"
}, },
"Point": { "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": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": { "T": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -2232,6 +2311,29 @@
}, },
"type": "object" "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": { "PostableGrafanaReceiver": {
"properties": { "properties": {
"disableResolveMessage": { "disableResolveMessage": {
@@ -2508,8 +2610,30 @@
}, },
"type": "array" "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": { "PushoverConfig": {
"properties": { "properties": {
"device": {
"type": "string"
},
"expire": { "expire": {
"type": "string" "type": "string"
}, },
@@ -2584,7 +2708,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "filterable": {
@@ -3007,6 +3131,9 @@
}, },
"Sample": { "Sample": {
"properties": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": { "Metric": {
"$ref": "#/definitions/Labels" "$ref": "#/definitions/Labels"
}, },
@@ -3201,6 +3328,22 @@
"SmtpNotEnabled": { "SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails" "$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": { "Status": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -3273,6 +3416,9 @@
}, },
"token": { "token": {
"$ref": "#/definitions/Secret" "$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
} }
}, },
"title": "TelegramConfig configures notifications via Telegram.", "title": "TelegramConfig configures notifications via Telegram.",
@@ -3684,7 +3830,10 @@
"type": "boolean" "type": "boolean"
}, },
"url": { "url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
} }
}, },
"title": "WebhookConfig configures notifications via a generic webhook.", "title": "WebhookConfig configures notifications via a generic webhook.",
@@ -3875,7 +4024,6 @@
"type": "object" "type": "object"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": { "properties": {
"annotations": { "annotations": {
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
@@ -3931,13 +4079,13 @@
"type": "object" "type": "object"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@@ -4136,6 +4284,7 @@
"type": "array" "type": "array"
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@@ -4173,7 +4322,6 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",
@@ -5116,6 +5264,21 @@
"type": "array" "type": "array"
} }
}, },
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": { "receiversResponse": {
"description": "", "description": "",
"schema": { "schema": {

View File

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql" "github.com/prometheus/prometheus/promql"
@@ -23,7 +24,9 @@ import (
// - application/json // - application/json
// //
// Responses: // Responses:
// 200: TestRuleResponse // 200: TestGrafanaRuleResponse
// 400: ValidationError
// 404: NotFound
// swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig // swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig
// //
@@ -71,7 +74,7 @@ type TestReceiverRequest struct {
Body ExtendedReceiver Body ExtendedReceiver
} }
// swagger:parameters RouteTestRuleConfig RouteTestRuleGrafanaConfig // swagger:parameters RouteTestRuleConfig
type TestRuleRequest struct { type TestRuleRequest struct {
// in:body // in:body
Body TestRulePayload Body TestRulePayload
@@ -85,6 +88,38 @@ type TestRulePayload struct {
GrafanaManagedCondition *EvalAlertConditionCommand `json:"grafana_condition,omitempty"` 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 // swagger:parameters RouteEvalQueries
type EvalQueriesRequest struct { type EvalQueriesRequest struct {
// in:body // in:body

View File

@@ -543,6 +543,12 @@
}, },
"type": "array" "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": { "DataLink": {
"description": "DataLink define what", "description": "DataLink define what",
"properties": { "properties": {
@@ -889,7 +895,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "filterable": {
@@ -952,6 +958,56 @@
}, },
"type": "object" "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": { "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.", "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": { "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.", "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" "type": "boolean"
}, },
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": { "oauth2": {
"$ref": "#/definitions/OAuth2" "$ref": "#/definitions/OAuth2"
}, },
"proxy_connect_header": { "proxy_connect_header": {
"$ref": "#/definitions/Header" "$ref": "#/definitions/Header"
}, },
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -1865,6 +1929,17 @@
}, },
"type": "object" "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": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -2064,7 +2139,11 @@
"type": "object" "type": "object"
}, },
"Point": { "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": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": { "T": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -2232,6 +2311,29 @@
}, },
"type": "object" "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": { "PostableGrafanaReceiver": {
"properties": { "properties": {
"disableResolveMessage": { "disableResolveMessage": {
@@ -2508,8 +2610,30 @@
}, },
"type": "array" "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": { "PushoverConfig": {
"properties": { "properties": {
"device": {
"type": "string"
},
"expire": { "expire": {
"type": "string" "type": "string"
}, },
@@ -2584,7 +2708,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "filterable": {
@@ -3007,6 +3131,9 @@
}, },
"Sample": { "Sample": {
"properties": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": { "Metric": {
"$ref": "#/definitions/Labels" "$ref": "#/definitions/Labels"
}, },
@@ -3201,6 +3328,22 @@
"SmtpNotEnabled": { "SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails" "$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": { "Status": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -3273,6 +3416,9 @@
}, },
"token": { "token": {
"$ref": "#/definitions/Secret" "$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
} }
}, },
"title": "TelegramConfig configures notifications via Telegram.", "title": "TelegramConfig configures notifications via Telegram.",
@@ -3535,6 +3681,7 @@
"type": "object" "type": "object"
}, },
"URL": { "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": { "properties": {
"ForceQuery": { "ForceQuery": {
"type": "boolean" "type": "boolean"
@@ -3570,7 +3717,7 @@
"$ref": "#/definitions/Userinfo" "$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" "type": "object"
}, },
"Userinfo": { "Userinfo": {
@@ -3684,7 +3831,10 @@
"type": "boolean" "type": "boolean"
}, },
"url": { "url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
} }
}, },
"title": "WebhookConfig configures notifications via a generic webhook.", "title": "WebhookConfig configures notifications via a generic webhook.",
@@ -3747,7 +3897,6 @@
"type": "object" "type": "object"
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"properties": { "properties": {
"alerts": { "alerts": {
"description": "alerts", "description": "alerts",
@@ -3771,7 +3920,6 @@
"type": "object" "type": "object"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
@@ -3876,6 +4024,7 @@
"type": "object" "type": "object"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": { "properties": {
"annotations": { "annotations": {
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
@@ -3931,12 +4080,14 @@
"type": "object" "type": "object"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@@ -3985,7 +4136,6 @@
"type": "object" "type": "object"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
@@ -4136,6 +4286,7 @@
"type": "array" "type": "array"
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@@ -4173,6 +4324,7 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",
@@ -6926,7 +7078,7 @@
"in": "body", "in": "body",
"name": "Body", "name": "Body",
"schema": { "schema": {
"$ref": "#/definitions/TestRulePayload" "$ref": "#/definitions/PostableExtendedRuleNodeExtended"
} }
} }
], ],
@@ -6935,9 +7087,18 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "TestRuleResponse", "$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": { "schema": {
"$ref": "#/definitions/TestRuleResponse" "$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
} }
} }
}, },
@@ -7022,6 +7183,21 @@
"type": "array" "type": "array"
} }
}, },
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"items": {
"$ref": "#/definitions/postableAlert"
},
"type": "array"
}
},
"receiversResponse": { "receiversResponse": {
"description": "", "description": "",
"schema": { "schema": {

View File

@@ -2683,15 +2683,24 @@
"name": "Body", "name": "Body",
"in": "body", "in": "body",
"schema": { "schema": {
"$ref": "#/definitions/TestRulePayload" "$ref": "#/definitions/PostableExtendedRuleNodeExtended"
} }
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "TestRuleResponse", "$ref": "#/responses/TestGrafanaRuleResponse"
},
"400": {
"description": "ValidationError",
"schema": { "schema": {
"$ref": "#/definitions/TestRuleResponse" "$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
} }
} }
} }
@@ -3300,6 +3309,12 @@
"$ref": "#/definitions/EmbeddedContactPoint" "$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": { "DataLink": {
"description": "DataLink define what", "description": "DataLink define what",
"type": "object", "type": "object",
@@ -3651,7 +3666,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "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": { "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.", "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", "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.", "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" "type": "boolean"
}, },
"no_proxy": {
"description": "NoProxy contains addresses that should not use a proxy.",
"type": "string"
},
"oauth2": { "oauth2": {
"$ref": "#/definitions/OAuth2" "$ref": "#/definitions/OAuth2"
}, },
"proxy_connect_header": { "proxy_connect_header": {
"$ref": "#/definitions/Header" "$ref": "#/definitions/Header"
}, },
"proxy_from_environment": {
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
"type": "boolean"
},
"proxy_url": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -4628,6 +4701,17 @@
"type": "string" "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": { "proxy_url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/URL"
}, },
@@ -4825,9 +4909,13 @@
"type": "object" "type": "object"
}, },
"Point": { "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", "type": "object",
"title": "Point represents a single data point for a given timestamp.", "title": "Point represents a single data point for a given timestamp.",
"properties": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"T": { "T": {
"type": "integer", "type": "integer",
"format": "int64" "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": { "PostableGrafanaReceiver": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -5269,9 +5380,31 @@
"$ref": "#/definitions/ProvisionedAlertRule" "$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": { "PushoverConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
"device": {
"type": "string"
},
"expire": { "expire": {
"type": "string" "type": "string"
}, },
@@ -5347,7 +5480,7 @@
"type": "string" "type": "string"
}, },
"displayNameFromDS": { "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" "type": "string"
}, },
"filterable": { "filterable": {
@@ -5770,6 +5903,9 @@
"type": "object", "type": "object",
"title": "Sample is a single sample belonging to a metric.", "title": "Sample is a single sample belonging to a metric.",
"properties": { "properties": {
"H": {
"$ref": "#/definitions/FloatHistogram"
},
"Metric": { "Metric": {
"$ref": "#/definitions/Labels" "$ref": "#/definitions/Labels"
}, },
@@ -5962,6 +6098,22 @@
"SmtpNotEnabled": { "SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails" "$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": { "Status": {
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
@@ -6036,6 +6188,9 @@
}, },
"token": { "token": {
"$ref": "#/definitions/Secret" "$ref": "#/definitions/Secret"
},
"token_file": {
"type": "string"
} }
} }
}, },
@@ -6296,8 +6451,9 @@
} }
}, },
"URL": { "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", "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": { "properties": {
"ForceQuery": { "ForceQuery": {
"type": "boolean" "type": "boolean"
@@ -6447,7 +6603,10 @@
"type": "boolean" "type": "boolean"
}, },
"url": { "url": {
"$ref": "#/definitions/URL" "$ref": "#/definitions/SecretURL"
},
"url_file": {
"type": "string"
} }
} }
}, },
@@ -6508,7 +6667,6 @@
} }
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"type": "object", "type": "object",
"required": [ "required": [
"alerts", "alerts",
@@ -6533,7 +6691,6 @@
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
@@ -6639,6 +6796,7 @@
} }
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object", "type": "object",
"required": [ "required": [
"labels", "labels",
@@ -6695,6 +6853,7 @@
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
@@ -6702,6 +6861,7 @@
"$ref": "#/definitions/gettableAlerts" "$ref": "#/definitions/gettableAlerts"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object", "type": "object",
"required": [ "required": [
"comment", "comment",
@@ -6751,7 +6911,6 @@
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
@@ -6904,6 +7063,7 @@
} }
}, },
"postableSilence": { "postableSilence": {
"description": "PostableSilence postable silence",
"type": "object", "type": "object",
"required": [ "required": [
"comment", "comment",
@@ -6942,6 +7102,7 @@
"$ref": "#/definitions/postableSilence" "$ref": "#/definitions/postableSilence"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"type": "object", "type": "object",
"required": [ "required": [
"active", "active",
@@ -7066,6 +7227,21 @@
} }
} }
}, },
"StateHistory": {
"description": "",
"schema": {
"$ref": "#/definitions/Frame"
}
},
"TestGrafanaRuleResponse": {
"description": "",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/postableAlert"
}
}
},
"receiversResponse": { "receiversResponse": {
"description": "", "description": "",
"schema": { "schema": {

View File

@@ -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) { func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) {
if alert.Labels == nil { if alert.Labels == nil {
alert.Labels = make(map[string]string) alert.Labels = make(map[string]string)

View File

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

View File

@@ -676,7 +676,7 @@ func TestSchedule_ruleRoutine(t *testing.T) {
args, ok := sender.Calls[0].Arguments[1].(definitions.PostableAlerts) 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])) 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.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])
}) })
}) })

View File

@@ -1,4 +1,4 @@
package schedule package state
import ( import (
"encoding/json" "encoding/json"
@@ -18,7 +18,6 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
) )
const ( const (
@@ -28,13 +27,13 @@ const (
Rulename = "rulename" 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 // - 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 // - 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: // - 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 // - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName
// - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName // - 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() nL := alertState.Labels.Copy()
nA := data.Labels(alertState.Annotations).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. // 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: // The Alert is defined as:
// { alertname=DatasourceNoData rulename=original_alertname } + { rule labelset } + { rule annotations } // { 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 { if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name 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 // 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. // 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 { if name, ok := labels[model.AlertNameLabel]; ok {
labels[Rulename] = name 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))} alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
var sentAlerts []*state.State var sentAlerts []*State
ts := time.Now() ts := time.Now()
for _, alertState := range firingStates { for _, alertState := range firingStates {
if !alertState.NeedsSending(stateManager.ResendDelay) { if !alertState.NeedsSending(stateManager.ResendDelay) {
continue continue
} }
alert := stateToPostableAlert(alertState.State, appURL) alert := StateToPostableAlert(alertState.State, appURL)
alerts.PostableAlerts = append(alerts.PostableAlerts, *alert) alerts.PostableAlerts = append(alerts.PostableAlerts, *alert)
if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager
continue 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) // 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 // 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))} alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
ts := clock.Now() ts := clock.Now()
for _, transition := range firingStates { for _, transition := range firingStates {
if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending { if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending {
continue continue
} }
postableAlert := stateToPostableAlert(transition.State, appURL) postableAlert := StateToPostableAlert(transition.State, appURL)
postableAlert.EndsAt = strfmt.DateTime(ts) postableAlert.EndsAt = strfmt.DateTime(ts)
alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert) alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert)
} }

View File

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

View File

@@ -8,7 +8,9 @@ import (
"strings" "strings"
"time" "time"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/grafana-plugin-sdk-go/data" "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/expr"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
@@ -397,3 +399,17 @@ func FormatStateAndReason(state eval.State, reason string) string {
} }
return s 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
}

View File

@@ -2119,237 +2119,6 @@ func TestIntegrationEval(t *testing.T) {
expectedStatusCode func() int expectedStatusCode func() int
expectedResponse func() string expectedResponse func() string
expectedMessage 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", desc: "alerting condition",

View File

@@ -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"
}`),
},
},
},
}
}
}

View File

@@ -334,3 +334,22 @@ func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.Backt
require.NoError(t, err) require.NoError(t, err)
return resp.StatusCode, string(b) 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)
}