mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -136,6 +136,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
cfg: &api.Cfg.UnifiedAlerting,
|
||||
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory),
|
||||
featureManager: api.FeatureManager,
|
||||
appUrl: api.AppUrl,
|
||||
}), m)
|
||||
api.RegisterConfigurationApiEndpoints(NewConfiguration(
|
||||
&ConfigSrv{
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/grafana/alerting/models"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -16,10 +19,12 @@ import (
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/backtesting"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -33,46 +38,73 @@ type TestingApiSrv struct {
|
||||
cfg *setting.UnifiedAlertingSettings
|
||||
backtesting *backtesting.Engine
|
||||
featureManager featuremgmt.FeatureToggles
|
||||
appUrl *url.URL
|
||||
}
|
||||
|
||||
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
|
||||
if body.Type() != apimodels.GrafanaBackend || body.GrafanaManagedCondition == nil {
|
||||
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, body.Type().String()))
|
||||
// RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be
|
||||
// as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to
|
||||
// only Resolved / Firing and ready to send.
|
||||
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
|
||||
rule, err := validateRuleNode(
|
||||
&body.Rule,
|
||||
body.RuleGroup,
|
||||
srv.cfg.BaseInterval,
|
||||
c.OrgID,
|
||||
&folder.Folder{
|
||||
OrgID: c.OrgID,
|
||||
UID: body.NamespaceUID,
|
||||
Title: body.NamespaceTitle,
|
||||
},
|
||||
func(condition ngmodels.Condition) error {
|
||||
return srv.evaluator.Validate(eval.NewContext(c.Req.Context(), c.SignedInUser), condition)
|
||||
},
|
||||
srv.cfg,
|
||||
)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
queries := AlertQueriesFromApiAlertQueries(body.GrafanaManagedCondition.Data)
|
||||
|
||||
if !authorizeDatasourceAccessForRule(&ngmodels.AlertRule{Data: queries}, func(evaluator accesscontrol.Evaluator) bool {
|
||||
if !authorizeDatasourceAccessForRule(rule, func(evaluator accesscontrol.Evaluator) bool {
|
||||
return accesscontrol.HasAccess(srv.accessControl, c)(evaluator)
|
||||
}) {
|
||||
return errorToResponse(fmt.Errorf("%w to query one or many data sources used by the rule", ErrAuthorization))
|
||||
}
|
||||
|
||||
evalCond := ngmodels.Condition{
|
||||
Condition: body.GrafanaManagedCondition.Condition,
|
||||
Data: queries,
|
||||
}
|
||||
ctx := eval.NewContext(c.Req.Context(), c.SignedInUser)
|
||||
|
||||
conditionEval, err := srv.evaluator.Create(ctx, evalCond)
|
||||
evaluator, err := srv.evaluator.Create(eval.NewContext(c.Req.Context(), c.SignedInUser), rule.GetEvalCondition())
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "invalid condition")
|
||||
return ErrResp(http.StatusBadRequest, err, "Failed to build evaluator for queries and expressions")
|
||||
}
|
||||
|
||||
now := body.GrafanaManagedCondition.Now
|
||||
if now.IsZero() {
|
||||
now = timeNow()
|
||||
}
|
||||
|
||||
evalResults, err := conditionEval.Evaluate(c.Req.Context(), now)
|
||||
now := time.Now()
|
||||
results, err := evaluator.Evaluate(c.Req.Context(), now)
|
||||
if err != nil {
|
||||
return ErrResp(500, err, "Failed to evaluate the rule")
|
||||
return ErrResp(http.StatusInternalServerError, err, "Failed to evaluate queries")
|
||||
}
|
||||
|
||||
frame := evalResults.AsDataFrame()
|
||||
return response.JSONStreaming(http.StatusOK, util.DynMap{
|
||||
"instances": []*data.Frame{&frame},
|
||||
})
|
||||
cfg := state.ManagerCfg{
|
||||
Metrics: nil,
|
||||
ExternalURL: srv.appUrl,
|
||||
InstanceStore: nil,
|
||||
Images: &backtesting.NoopImageService{},
|
||||
Clock: clock.New(),
|
||||
Historian: nil,
|
||||
}
|
||||
manager := state.NewManager(cfg)
|
||||
includeFolder := !srv.cfg.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel)
|
||||
transitions := manager.ProcessEvalResults(
|
||||
c.Req.Context(),
|
||||
now,
|
||||
rule,
|
||||
results,
|
||||
state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder),
|
||||
)
|
||||
|
||||
alerts := make([]*amv2.PostableAlert, 0, len(transitions))
|
||||
for _, alertState := range transitions {
|
||||
alerts = append(alerts, state.StateToPostableAlert(alertState.State, srv.appUrl))
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, alerts)
|
||||
}
|
||||
|
||||
func (srv TestingApiSrv) RouteTestRuleConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload, datasourceUID string) response.Response {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -22,6 +23,107 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
text := `{
|
||||
"rule": {
|
||||
"grafana_alert" : {
|
||||
"condition": "C",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 600,
|
||||
"to": 0
|
||||
},
|
||||
"queryType": "",
|
||||
"datasourceUid": "PD8C576611E62080A",
|
||||
"model": {
|
||||
"refId": "A",
|
||||
"hide": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 5,
|
||||
"labels": "series=series-$seriesIndex"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasourceUid": "__expr__",
|
||||
"queryType": "",
|
||||
"model": {
|
||||
"refId": "B",
|
||||
"hide": false,
|
||||
"type": "reduce",
|
||||
"datasource": {
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__"
|
||||
},
|
||||
"reducer": "last",
|
||||
"expression": "A"
|
||||
},
|
||||
"relativeTimeRange": {
|
||||
"from": 600,
|
||||
"to": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
"datasourceUid": "__expr__",
|
||||
"queryType": "",
|
||||
"model": {
|
||||
"refId": "C",
|
||||
"hide": false,
|
||||
"type": "threshold",
|
||||
"datasource": {
|
||||
"uid": "__expr__",
|
||||
"type": "__expr__"
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"evaluator": {
|
||||
"params": [
|
||||
0
|
||||
],
|
||||
"type": "gt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expression": "B"
|
||||
},
|
||||
"relativeTimeRange": {
|
||||
"from": 600,
|
||||
"to": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"no_data_state": "Alerting",
|
||||
"title": "string"
|
||||
},
|
||||
"for": "0s",
|
||||
"labels": {
|
||||
"additionalProp1": "string",
|
||||
"additionalProp2": "string",
|
||||
"additionalProp3": "string"
|
||||
},
|
||||
"annotations": {
|
||||
"additionalProp1": "string",
|
||||
"additionalProp2": "string",
|
||||
"additionalProp3": "string"
|
||||
}
|
||||
},
|
||||
"folderUid": "test-uid",
|
||||
"folderTitle": "test-folder"
|
||||
}`
|
||||
var conf definitions.PostableExtendedRuleNodeExtended
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &conf))
|
||||
|
||||
require.Equal(t, "test-folder", conf.NamespaceTitle)
|
||||
}
|
||||
|
||||
func TestRouteTestGrafanaRuleConfig(t *testing.T) {
|
||||
t.Run("when fine-grained access is enabled", func(t *testing.T) {
|
||||
rc := &contextmodel.ReqContext{
|
||||
@@ -41,15 +143,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
|
||||
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
|
||||
})
|
||||
|
||||
srv := createTestingApiSrv(nil, ac, nil)
|
||||
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}))
|
||||
|
||||
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
|
||||
Expr: "",
|
||||
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
|
||||
Condition: data1.RefID,
|
||||
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
|
||||
Now: time.Time{},
|
||||
},
|
||||
rule := validRule()
|
||||
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
|
||||
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
|
||||
Rule: rule,
|
||||
NamespaceUID: "test-folder",
|
||||
NamespaceTitle: "test-folder",
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, response.Status())
|
||||
@@ -59,8 +160,6 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
|
||||
data1 := models.GenerateAlertQuery()
|
||||
data2 := models.GenerateAlertQuery()
|
||||
|
||||
currentTime := time.Now()
|
||||
|
||||
ac := acMock.New().WithPermissions([]accesscontrol.Permission{
|
||||
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
|
||||
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data2.DatasourceUID)},
|
||||
@@ -77,20 +176,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
|
||||
|
||||
evalFactory := eval_mocks.NewEvaluatorFactory(evaluator)
|
||||
|
||||
srv := createTestingApiSrv(ds, ac, evalFactory)
|
||||
srv := createTestingApiSrv(t, ds, ac, evalFactory)
|
||||
|
||||
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.TestRulePayload{
|
||||
Expr: "",
|
||||
GrafanaManagedCondition: &definitions.EvalAlertConditionCommand{
|
||||
Condition: data1.RefID,
|
||||
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
|
||||
Now: currentTime,
|
||||
},
|
||||
rule := validRule()
|
||||
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
|
||||
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
|
||||
Rule: rule,
|
||||
NamespaceUID: "test-folder",
|
||||
NamespaceTitle: "test-folder",
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Status())
|
||||
|
||||
evaluator.AssertCalled(t, "Evaluate", mock.Anything, currentTime)
|
||||
evaluator.AssertCalled(t, "Evaluate", mock.Anything, mock.Anything)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -153,7 +251,7 @@ func TestRouteEvalQueries(t *testing.T) {
|
||||
}
|
||||
evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil)
|
||||
|
||||
srv := createTestingApiSrv(ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
|
||||
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator))
|
||||
|
||||
response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
|
||||
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
|
||||
@@ -167,7 +265,7 @@ func TestRouteEvalQueries(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
|
||||
func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory) *TestingApiSrv {
|
||||
if ac == nil {
|
||||
ac = acMock.New().WithDisabled()
|
||||
}
|
||||
@@ -176,5 +274,6 @@ func createTestingApiSrv(ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator
|
||||
DatasourceCache: ds,
|
||||
accessControl: ac,
|
||||
evaluator: evaluator,
|
||||
cfg: config(t),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (f *TestingApiHandler) RouteTestRuleConfig(ctx *contextmodel.ReqContext) re
|
||||
}
|
||||
func (f *TestingApiHandler) RouteTestRuleGrafanaConfig(ctx *contextmodel.ReqContext) response.Response {
|
||||
// Parse Request Body
|
||||
conf := apimodels.TestRulePayload{}
|
||||
conf := apimodels.PostableExtendedRuleNodeExtended{}
|
||||
if err := web.Bind(ctx.Req, &conf); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func (f *TestingApiHandler) handleRouteTestRuleConfig(c *contextmodel.ReqContext
|
||||
return f.svc.RouteTestRuleConfig(c, body, dsUID)
|
||||
}
|
||||
|
||||
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.TestRulePayload) response.Response {
|
||||
func (f *TestingApiHandler) handleRouteTestRuleGrafanaConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
|
||||
return f.svc.RouteTestGrafanaRuleConfig(c, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -543,6 +543,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
|
||||
"format": "uint8",
|
||||
"title": "CounterResetHint contains the known information about a counter reset,",
|
||||
"type": "integer"
|
||||
},
|
||||
"DataLink": {
|
||||
"description": "DataLink define what",
|
||||
"properties": {
|
||||
@@ -889,7 +895,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -952,6 +958,56 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FloatHistogram": {
|
||||
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
|
||||
"properties": {
|
||||
"Count": {
|
||||
"description": "Total number of observations. Must be zero or positive.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"items": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PositiveSpans": {
|
||||
"description": "Spans for positive and negative buckets (see Span below).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Span"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"Sum": {
|
||||
"description": "Sum of observations. This is also used as the stale marker.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"ZeroCount": {
|
||||
"description": "Observations falling into the zero bucket. Must be zero or positive.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"ZeroThreshold": {
|
||||
"description": "Width of the zero bucket.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
|
||||
"type": "object"
|
||||
},
|
||||
"Frame": {
|
||||
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
|
||||
"properties": {
|
||||
@@ -1553,12 +1609,20 @@
|
||||
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth2": {
|
||||
"$ref": "#/definitions/OAuth2"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -1865,6 +1929,17 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -2064,7 +2139,11 @@
|
||||
"type": "object"
|
||||
},
|
||||
"Point": {
|
||||
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"T": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2232,6 +2311,29 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PostableExtendedRuleNodeExtended": {
|
||||
"properties": {
|
||||
"folderTitle": {
|
||||
"example": "project_x",
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"example": "okrd3I0Vz",
|
||||
"type": "string"
|
||||
},
|
||||
"rule": {
|
||||
"$ref": "#/definitions/PostableExtendedRuleNode"
|
||||
},
|
||||
"ruleGroup": {
|
||||
"example": "eval_group_1",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rule"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PostableGrafanaReceiver": {
|
||||
"properties": {
|
||||
"disableResolveMessage": {
|
||||
@@ -2508,8 +2610,30 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ProxyConfig": {
|
||||
"properties": {
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PushoverConfig": {
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"expire": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2584,7 +2708,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -3007,6 +3131,9 @@
|
||||
},
|
||||
"Sample": {
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"Metric": {
|
||||
"$ref": "#/definitions/Labels"
|
||||
},
|
||||
@@ -3201,6 +3328,22 @@
|
||||
"SmtpNotEnabled": {
|
||||
"$ref": "#/definitions/ResponseDetails"
|
||||
},
|
||||
"Span": {
|
||||
"properties": {
|
||||
"Length": {
|
||||
"description": "Length of the span.",
|
||||
"format": "uint32",
|
||||
"type": "integer"
|
||||
},
|
||||
"Offset": {
|
||||
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "A Span defines a continuous sequence of buckets.",
|
||||
"type": "object"
|
||||
},
|
||||
"Status": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -3273,6 +3416,9 @@
|
||||
},
|
||||
"token": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
},
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "TelegramConfig configures notifications via Telegram.",
|
||||
@@ -3684,7 +3830,10 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
"$ref": "#/definitions/SecretURL"
|
||||
},
|
||||
"url_file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "WebhookConfig configures notifications via a generic webhook.",
|
||||
@@ -3875,7 +4024,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"properties": {
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/labelSet"
|
||||
@@ -3931,13 +4079,13 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"gettableSilence": {
|
||||
"description": "GettableSilence gettable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@@ -4136,6 +4284,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@@ -4173,7 +4322,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"receiver": {
|
||||
"description": "Receiver receiver",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "active",
|
||||
@@ -5116,6 +5264,21 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"StateHistory": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Frame"
|
||||
}
|
||||
},
|
||||
"TestGrafanaRuleResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/postableAlert"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"receiversResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
|
||||
@@ -12,6 +12,8 @@ import "github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
// Responses:
|
||||
// 200: StateHistory
|
||||
|
||||
// swagger:response StateHistory
|
||||
type StateHistory struct {
|
||||
// in:body
|
||||
Results *data.Frame `json:"results"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
@@ -23,7 +24,9 @@ import (
|
||||
// - application/json
|
||||
//
|
||||
// Responses:
|
||||
// 200: TestRuleResponse
|
||||
// 200: TestGrafanaRuleResponse
|
||||
// 400: ValidationError
|
||||
// 404: NotFound
|
||||
|
||||
// swagger:route Post /api/v1/rule/test/{DatasourceUID} testing RouteTestRuleConfig
|
||||
//
|
||||
@@ -71,7 +74,7 @@ type TestReceiverRequest struct {
|
||||
Body ExtendedReceiver
|
||||
}
|
||||
|
||||
// swagger:parameters RouteTestRuleConfig RouteTestRuleGrafanaConfig
|
||||
// swagger:parameters RouteTestRuleConfig
|
||||
type TestRuleRequest struct {
|
||||
// in:body
|
||||
Body TestRulePayload
|
||||
@@ -85,6 +88,38 @@ type TestRulePayload struct {
|
||||
GrafanaManagedCondition *EvalAlertConditionCommand `json:"grafana_condition,omitempty"`
|
||||
}
|
||||
|
||||
// swagger:response TestGrafanaRuleResponse
|
||||
type TestGrafanaRuleResponse struct {
|
||||
// in:body
|
||||
Body []amv2.PostableAlert
|
||||
}
|
||||
|
||||
// swagger:parameters RouteTestRuleGrafanaConfig
|
||||
type TestGrafanaRuleRequest struct {
|
||||
// in:body
|
||||
Body PostableExtendedRuleNodeExtended
|
||||
}
|
||||
|
||||
// swagger:model
|
||||
type PostableExtendedRuleNodeExtended struct {
|
||||
// required: true
|
||||
Rule PostableExtendedRuleNode `json:"rule"`
|
||||
// example: okrd3I0Vz
|
||||
NamespaceUID string `json:"folderUid"`
|
||||
// example: project_x
|
||||
NamespaceTitle string `json:"folderTitle"`
|
||||
// example: eval_group_1
|
||||
RuleGroup string `json:"ruleGroup"`
|
||||
}
|
||||
|
||||
func (n *PostableExtendedRuleNodeExtended) UnmarshalJSON(b []byte) error {
|
||||
type plain PostableExtendedRuleNodeExtended
|
||||
if err := json.Unmarshal(b, (*plain)(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// swagger:parameters RouteEvalQueries
|
||||
type EvalQueriesRequest struct {
|
||||
// in:body
|
||||
|
||||
@@ -543,6 +543,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
|
||||
"format": "uint8",
|
||||
"title": "CounterResetHint contains the known information about a counter reset,",
|
||||
"type": "integer"
|
||||
},
|
||||
"DataLink": {
|
||||
"description": "DataLink define what",
|
||||
"properties": {
|
||||
@@ -889,7 +895,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -952,6 +958,56 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FloatHistogram": {
|
||||
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
|
||||
"properties": {
|
||||
"Count": {
|
||||
"description": "Total number of observations. Must be zero or positive.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"items": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PositiveSpans": {
|
||||
"description": "Spans for positive and negative buckets (see Span below).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Span"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"Sum": {
|
||||
"description": "Sum of observations. This is also used as the stale marker.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"ZeroCount": {
|
||||
"description": "Observations falling into the zero bucket. Must be zero or positive.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"ZeroThreshold": {
|
||||
"description": "Width of the zero bucket.",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
|
||||
"type": "object"
|
||||
},
|
||||
"Frame": {
|
||||
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
|
||||
"properties": {
|
||||
@@ -1553,12 +1609,20 @@
|
||||
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth2": {
|
||||
"$ref": "#/definitions/OAuth2"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -1865,6 +1929,17 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -2064,7 +2139,11 @@
|
||||
"type": "object"
|
||||
},
|
||||
"Point": {
|
||||
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"T": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2232,6 +2311,29 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PostableExtendedRuleNodeExtended": {
|
||||
"properties": {
|
||||
"folderTitle": {
|
||||
"example": "project_x",
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"example": "okrd3I0Vz",
|
||||
"type": "string"
|
||||
},
|
||||
"rule": {
|
||||
"$ref": "#/definitions/PostableExtendedRuleNode"
|
||||
},
|
||||
"ruleGroup": {
|
||||
"example": "eval_group_1",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rule"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PostableGrafanaReceiver": {
|
||||
"properties": {
|
||||
"disableResolveMessage": {
|
||||
@@ -2508,8 +2610,30 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ProxyConfig": {
|
||||
"properties": {
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PushoverConfig": {
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"expire": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2584,7 +2708,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -3007,6 +3131,9 @@
|
||||
},
|
||||
"Sample": {
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"Metric": {
|
||||
"$ref": "#/definitions/Labels"
|
||||
},
|
||||
@@ -3201,6 +3328,22 @@
|
||||
"SmtpNotEnabled": {
|
||||
"$ref": "#/definitions/ResponseDetails"
|
||||
},
|
||||
"Span": {
|
||||
"properties": {
|
||||
"Length": {
|
||||
"description": "Length of the span.",
|
||||
"format": "uint32",
|
||||
"type": "integer"
|
||||
},
|
||||
"Offset": {
|
||||
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "A Span defines a continuous sequence of buckets.",
|
||||
"type": "object"
|
||||
},
|
||||
"Status": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -3273,6 +3416,9 @@
|
||||
},
|
||||
"token": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
},
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "TelegramConfig configures notifications via Telegram.",
|
||||
@@ -3535,6 +3681,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"URL": {
|
||||
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
|
||||
"properties": {
|
||||
"ForceQuery": {
|
||||
"type": "boolean"
|
||||
@@ -3570,7 +3717,7 @@
|
||||
"$ref": "#/definitions/Userinfo"
|
||||
}
|
||||
},
|
||||
"title": "URL is a custom URL type that allows validation at configuration load time.",
|
||||
"title": "A URL represents a parsed URL (technically, a URI reference).",
|
||||
"type": "object"
|
||||
},
|
||||
"Userinfo": {
|
||||
@@ -3684,7 +3831,10 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
"$ref": "#/definitions/SecretURL"
|
||||
},
|
||||
"url_file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "WebhookConfig configures notifications via a generic webhook.",
|
||||
@@ -3747,7 +3897,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroup": {
|
||||
"description": "AlertGroup alert group",
|
||||
"properties": {
|
||||
"alerts": {
|
||||
"description": "alerts",
|
||||
@@ -3771,7 +3920,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
@@ -3876,6 +4024,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"properties": {
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/labelSet"
|
||||
@@ -3931,12 +4080,14 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"gettableSilence": {
|
||||
"description": "GettableSilence gettable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@@ -3985,7 +4136,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
@@ -4136,6 +4286,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@@ -4173,6 +4324,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"receiver": {
|
||||
"description": "Receiver receiver",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "active",
|
||||
@@ -6926,7 +7078,7 @@
|
||||
"in": "body",
|
||||
"name": "Body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/TestRulePayload"
|
||||
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -6935,9 +7087,18 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "TestRuleResponse",
|
||||
"$ref": "#/responses/TestGrafanaRuleResponse"
|
||||
},
|
||||
"400": {
|
||||
"description": "ValidationError",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/TestRuleResponse"
|
||||
"$ref": "#/definitions/ValidationError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "NotFound",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/NotFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7022,6 +7183,21 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"StateHistory": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Frame"
|
||||
}
|
||||
},
|
||||
"TestGrafanaRuleResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/postableAlert"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"receiversResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
|
||||
@@ -2683,15 +2683,24 @@
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/TestRulePayload"
|
||||
"$ref": "#/definitions/PostableExtendedRuleNodeExtended"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "TestRuleResponse",
|
||||
"$ref": "#/responses/TestGrafanaRuleResponse"
|
||||
},
|
||||
"400": {
|
||||
"description": "ValidationError",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/TestRuleResponse"
|
||||
"$ref": "#/definitions/ValidationError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "NotFound",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3300,6 +3309,12 @@
|
||||
"$ref": "#/definitions/EmbeddedContactPoint"
|
||||
}
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"description": "or alternatively that we are dealing with a gauge histogram, where counter resets do not apply.",
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"title": "CounterResetHint contains the known information about a counter reset,"
|
||||
},
|
||||
"DataLink": {
|
||||
"description": "DataLink define what",
|
||||
"type": "object",
|
||||
@@ -3651,7 +3666,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -3712,6 +3727,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FloatHistogram": {
|
||||
"description": "A FloatHistogram is needed by PromQL to handle operations that might result\nin fractional counts. Since the counts in a histogram are unlikely to be too\nlarge to be represented precisely by a float64, a FloatHistogram can also be\nused to represent a histogram with integer counts and thus serves as a more\ngeneralized representation.",
|
||||
"type": "object",
|
||||
"title": "FloatHistogram is similar to Histogram but uses float64 for all\ncounts. Additionally, bucket counts are absolute and not deltas.",
|
||||
"properties": {
|
||||
"Count": {
|
||||
"description": "Total number of observations. Must be zero or positive.",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"PositiveSpans": {
|
||||
"description": "Spans for positive and negative buckets (see Span below).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Span"
|
||||
}
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"Sum": {
|
||||
"description": "Sum of observations. This is also used as the stale marker.",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"ZeroCount": {
|
||||
"description": "Observations falling into the zero bucket. Must be zero or positive.",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"ZeroThreshold": {
|
||||
"description": "Width of the zero bucket.",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Frame": {
|
||||
"description": "Each Field is well typed by its FieldType and supports optional Labels.\n\nA Frame is a general data container for Grafana. A Frame can be table data\nor time series data depending on its content and field types.",
|
||||
"type": "object",
|
||||
@@ -4315,12 +4380,20 @@
|
||||
"description": "FollowRedirects specifies whether the client should follow HTTP 3xx redirects.\nThe omitempty flag is not set, because it would be hidden from the\nmarshalled configuration when set to false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth2": {
|
||||
"$ref": "#/definitions/OAuth2"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -4628,6 +4701,17 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
},
|
||||
@@ -4825,9 +4909,13 @@
|
||||
"type": "object"
|
||||
},
|
||||
"Point": {
|
||||
"description": "If H is not nil, then this is a histogram point and only (T, H) is valid.\nIf H is nil, then only (T, V) is valid.",
|
||||
"type": "object",
|
||||
"title": "Point represents a single data point for a given timestamp.",
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"T": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -4993,6 +5081,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PostableExtendedRuleNodeExtended": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"rule"
|
||||
],
|
||||
"properties": {
|
||||
"folderTitle": {
|
||||
"type": "string",
|
||||
"example": "project_x"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string",
|
||||
"example": "okrd3I0Vz"
|
||||
},
|
||||
"rule": {
|
||||
"$ref": "#/definitions/PostableExtendedRuleNode"
|
||||
},
|
||||
"ruleGroup": {
|
||||
"type": "string",
|
||||
"example": "eval_group_1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PostableGrafanaReceiver": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5269,9 +5380,31 @@
|
||||
"$ref": "#/definitions/ProvisionedAlertRule"
|
||||
}
|
||||
},
|
||||
"ProxyConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"no_proxy": {
|
||||
"description": "NoProxy contains addresses that should not use a proxy.",
|
||||
"type": "string"
|
||||
},
|
||||
"proxy_connect_header": {
|
||||
"$ref": "#/definitions/Header"
|
||||
},
|
||||
"proxy_from_environment": {
|
||||
"description": "ProxyFromEnvironment makes use of net/http ProxyFromEnvironment function\nto determine proxies.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy_url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PushoverConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"expire": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5347,7 +5480,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"displayNameFromDS": {
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming in a better way that allows users to override it easily.",
|
||||
"description": "DisplayNameFromDS overrides Grafana default naming strategy.",
|
||||
"type": "string"
|
||||
},
|
||||
"filterable": {
|
||||
@@ -5770,6 +5903,9 @@
|
||||
"type": "object",
|
||||
"title": "Sample is a single sample belonging to a metric.",
|
||||
"properties": {
|
||||
"H": {
|
||||
"$ref": "#/definitions/FloatHistogram"
|
||||
},
|
||||
"Metric": {
|
||||
"$ref": "#/definitions/Labels"
|
||||
},
|
||||
@@ -5962,6 +6098,22 @@
|
||||
"SmtpNotEnabled": {
|
||||
"$ref": "#/definitions/ResponseDetails"
|
||||
},
|
||||
"Span": {
|
||||
"type": "object",
|
||||
"title": "A Span defines a continuous sequence of buckets.",
|
||||
"properties": {
|
||||
"Length": {
|
||||
"description": "Length of the span.",
|
||||
"type": "integer",
|
||||
"format": "uint32"
|
||||
},
|
||||
"Offset": {
|
||||
"description": "Gap to previous span (always positive), or starting index for the 1st\nspan (which can be negative).",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Status": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -6036,6 +6188,9 @@
|
||||
},
|
||||
"token": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
},
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6296,8 +6451,9 @@
|
||||
}
|
||||
},
|
||||
"URL": {
|
||||
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
|
||||
"type": "object",
|
||||
"title": "URL is a custom URL type that allows validation at configuration load time.",
|
||||
"title": "A URL represents a parsed URL (technically, a URI reference).",
|
||||
"properties": {
|
||||
"ForceQuery": {
|
||||
"type": "boolean"
|
||||
@@ -6447,7 +6603,10 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"url": {
|
||||
"$ref": "#/definitions/URL"
|
||||
"$ref": "#/definitions/SecretURL"
|
||||
},
|
||||
"url_file": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6508,7 +6667,6 @@
|
||||
}
|
||||
},
|
||||
"alertGroup": {
|
||||
"description": "AlertGroup alert group",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"alerts",
|
||||
@@ -6533,7 +6691,6 @@
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
@@ -6639,6 +6796,7 @@
|
||||
}
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"labels",
|
||||
@@ -6695,6 +6853,7 @@
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
@@ -6702,6 +6861,7 @@
|
||||
"$ref": "#/definitions/gettableAlerts"
|
||||
},
|
||||
"gettableSilence": {
|
||||
"description": "GettableSilence gettable silence",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment",
|
||||
@@ -6751,7 +6911,6 @@
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
@@ -6904,6 +7063,7 @@
|
||||
}
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment",
|
||||
@@ -6942,6 +7102,7 @@
|
||||
"$ref": "#/definitions/postableSilence"
|
||||
},
|
||||
"receiver": {
|
||||
"description": "Receiver receiver",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"active",
|
||||
@@ -7066,6 +7227,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"StateHistory": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Frame"
|
||||
}
|
||||
},
|
||||
"TestGrafanaRuleResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/postableAlert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"receiversResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
|
||||
@@ -41,7 +41,7 @@ func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTempla
|
||||
})
|
||||
}
|
||||
|
||||
// addDefaultLabelsAndAnnotations is a slimmed down version of schedule.stateToPostableAlert and schedule.getRuleExtraLabels using default values.
|
||||
// addDefaultLabelsAndAnnotations is a slimmed down version of state.StateToPostableAlert and state.GetRuleExtraLabels using default values.
|
||||
func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) {
|
||||
if alert.Labels == nil {
|
||||
alert.Labels = make(map[string]string)
|
||||
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
prometheusModel "github.com/prometheus/common/model"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
@@ -355,7 +353,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
|
||||
evalTotalFailures := sch.metrics.EvalFailures.WithLabelValues(orgID)
|
||||
|
||||
notify := func(states []state.StateTransition) {
|
||||
expiredAlerts := FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
|
||||
expiredAlerts := state.FromAlertsStateToStoppedAlert(states, sch.appURL, sch.clock)
|
||||
if len(expiredAlerts.PostableAlerts) > 0 {
|
||||
sch.alertsSender.Send(key, expiredAlerts)
|
||||
}
|
||||
@@ -425,8 +423,14 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR
|
||||
logger.Debug("Skip updating the state because the context has been cancelled")
|
||||
return
|
||||
}
|
||||
processedStates := sch.stateManager.ProcessEvalResults(ctx, e.scheduledAt, e.rule, results, sch.getRuleExtraLabels(e))
|
||||
alerts := FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
|
||||
processedStates := sch.stateManager.ProcessEvalResults(
|
||||
ctx,
|
||||
e.scheduledAt,
|
||||
e.rule,
|
||||
results,
|
||||
state.GetRuleExtraLabels(e.rule, e.folderTitle, !sch.disableGrafanaFolder),
|
||||
)
|
||||
alerts := state.FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL)
|
||||
span.AddEvents(
|
||||
[]string{"message", "state_transitions", "alerts_to_send"},
|
||||
[]tracing.EventValue{
|
||||
@@ -558,19 +562,6 @@ func (sch *schedule) stopApplied(alertDefKey ngmodels.AlertRuleKey) {
|
||||
sch.stopAppliedFunc(alertDefKey)
|
||||
}
|
||||
|
||||
func (sch *schedule) getRuleExtraLabels(evalCtx *evaluation) map[string]string {
|
||||
extraLabels := make(map[string]string, 4)
|
||||
|
||||
extraLabels[alertingModels.NamespaceUIDLabel] = evalCtx.rule.NamespaceUID
|
||||
extraLabels[prometheusModel.AlertNameLabel] = evalCtx.rule.Title
|
||||
extraLabels[alertingModels.RuleUIDLabel] = evalCtx.rule.UID
|
||||
|
||||
if !sch.disableGrafanaFolder {
|
||||
extraLabels[ngmodels.FolderTitleLabel] = evalCtx.folderTitle
|
||||
}
|
||||
return extraLabels
|
||||
}
|
||||
|
||||
func SchedulerUserFor(orgID int64) *user.SignedInUser {
|
||||
return &user.SignedInUser{
|
||||
UserID: -1,
|
||||
|
||||
@@ -676,7 +676,7 @@ func TestSchedule_ruleRoutine(t *testing.T) {
|
||||
args, ok := sender.Calls[0].Arguments[1].(definitions.PostableAlerts)
|
||||
require.Truef(t, ok, fmt.Sprintf("expected argument of function was supposed to be 'definitions.PostableAlerts' but got %T", sender.Calls[0].Arguments[1]))
|
||||
assert.Len(t, args.PostableAlerts, 1)
|
||||
assert.Equal(t, ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
|
||||
assert.Equal(t, state.ErrorAlertName, args.PostableAlerts[0].Labels[prometheusModel.AlertNameLabel])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package schedule
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,13 +27,13 @@ const (
|
||||
Rulename = "rulename"
|
||||
)
|
||||
|
||||
// stateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
|
||||
// StateToPostableAlert converts a state to a model that is accepted by Alertmanager. Annotations and Labels are copied from the state.
|
||||
// - if state has at least one result, a new label '__value_string__' is added to the label set
|
||||
// - the alert's GeneratorURL is constructed to point to the alert detail view
|
||||
// - if evaluation state is either NoData or Error, the resulting set of labels is changed:
|
||||
// - original alert name (label: model.AlertNameLabel) is backed up to OriginalAlertName
|
||||
// - label model.AlertNameLabel is overwritten to either NoDataAlertName or ErrorAlertName
|
||||
func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.PostableAlert {
|
||||
func StateToPostableAlert(alertState *State, appURL *url.URL) *models.PostableAlert {
|
||||
nL := alertState.Labels.Copy()
|
||||
nA := data.Labels(alertState.Annotations).Copy()
|
||||
|
||||
@@ -95,7 +94,7 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post
|
||||
// It effectively replaces the legacy behavior of "Keep Last State" by separating the regular alerting flow from the no data scenario into a separate alerts.
|
||||
// The Alert is defined as:
|
||||
// { alertname=DatasourceNoData rulename=original_alertname } + { rule labelset } + { rule annotations }
|
||||
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
|
||||
func noDataAlert(labels data.Labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
|
||||
if name, ok := labels[model.AlertNameLabel]; ok {
|
||||
labels[Rulename] = name
|
||||
}
|
||||
@@ -114,7 +113,7 @@ func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state.
|
||||
|
||||
// errorAlert is a special alert sent when evaluation of an alert rule failed due to an error. Like noDataAlert, it
|
||||
// replaces the old behaviour of "Keep Last State" creating a separate alert called DatasourceError.
|
||||
func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert {
|
||||
func errorAlert(labels, annotations data.Labels, alertState *State, urlStr string) *models.PostableAlert {
|
||||
if name, ok := labels[model.AlertNameLabel]; ok {
|
||||
labels[Rulename] = name
|
||||
}
|
||||
@@ -131,16 +130,16 @@ func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr
|
||||
}
|
||||
}
|
||||
|
||||
func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts {
|
||||
func FromStateTransitionToPostableAlerts(firingStates []StateTransition, stateManager *Manager, appURL *url.URL) apimodels.PostableAlerts {
|
||||
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
|
||||
var sentAlerts []*state.State
|
||||
var sentAlerts []*State
|
||||
ts := time.Now()
|
||||
|
||||
for _, alertState := range firingStates {
|
||||
if !alertState.NeedsSending(stateManager.ResendDelay) {
|
||||
continue
|
||||
}
|
||||
alert := stateToPostableAlert(alertState.State, appURL)
|
||||
alert := StateToPostableAlert(alertState.State, appURL)
|
||||
alerts.PostableAlerts = append(alerts.PostableAlerts, *alert)
|
||||
if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager
|
||||
continue
|
||||
@@ -154,14 +153,14 @@ func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, s
|
||||
|
||||
// FromAlertsStateToStoppedAlert selects only transitions from firing states (states eval.Alerting, eval.NoData, eval.Error)
|
||||
// and converts them to models.PostableAlert with EndsAt set to time.Now
|
||||
func FromAlertsStateToStoppedAlert(firingStates []state.StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
|
||||
func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.URL, clock clock.Clock) apimodels.PostableAlerts {
|
||||
alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))}
|
||||
ts := clock.Now()
|
||||
for _, transition := range firingStates {
|
||||
if transition.PreviousState == eval.Normal || transition.PreviousState == eval.Pending {
|
||||
continue
|
||||
}
|
||||
postableAlert := stateToPostableAlert(transition.State, appURL)
|
||||
postableAlert := StateToPostableAlert(transition.State, appURL)
|
||||
postableAlert.EndsAt = strfmt.DateTime(ts)
|
||||
alerts.PostableAlerts = append(alerts.PostableAlerts, *postableAlert)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package schedule
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func Test_stateToPostableAlert(t *testing.T) {
|
||||
func Test_StateToPostableAlert(t *testing.T) {
|
||||
appURL := &url.URL{
|
||||
Scheme: "http:",
|
||||
Host: fmt.Sprintf("host-%d", rand.Int()),
|
||||
@@ -59,7 +58,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
t.Run("to alert rule", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
u := *appURL
|
||||
u.Path = u.Path + "/alerting/grafana/" + alertState.AlertRuleUID + "/view"
|
||||
require.Equal(t, u.String(), result.Alert.GeneratorURL.String())
|
||||
@@ -68,25 +67,25 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
t.Run("app URL as is if rule UID is not specified", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Labels[alertingModels.RuleUIDLabel] = ""
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
|
||||
|
||||
delete(alertState.Labels, alertingModels.RuleUIDLabel)
|
||||
result = stateToPostableAlert(alertState, appURL)
|
||||
result = StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, appURL.String(), result.Alert.GeneratorURL.String())
|
||||
})
|
||||
|
||||
t.Run("empty string if app URL is not provided", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Labels[alertingModels.RuleUIDLabel] = alertState.AlertRuleUID
|
||||
result := stateToPostableAlert(alertState, nil)
|
||||
result := StateToPostableAlert(alertState, nil)
|
||||
require.Equal(t, "", result.Alert.GeneratorURL.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Start and End timestamps should be the same", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, strfmt.DateTime(alertState.StartsAt), result.StartsAt)
|
||||
require.Equal(t, strfmt.DateTime(alertState.EndsAt), result.EndsAt)
|
||||
})
|
||||
@@ -94,7 +93,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
t.Run("should copy annotations", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Annotations = randomMapOfStrings()
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, models.LabelSet(alertState.Annotations), result.Annotations)
|
||||
|
||||
t.Run("add __value_string__ if it has results", func(t *testing.T) {
|
||||
@@ -103,7 +102,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
expectedValueString := util.GenerateShortUID()
|
||||
alertState.LastEvaluationString = expectedValueString
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
expected := make(models.LabelSet, len(alertState.Annotations)+1)
|
||||
for k, v := range alertState.Annotations {
|
||||
@@ -115,7 +114,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
|
||||
// even overwrites
|
||||
alertState.Annotations["__value_string__"] = util.GenerateShortUID()
|
||||
result = stateToPostableAlert(alertState, appURL)
|
||||
result = StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, expected, result.Annotations)
|
||||
})
|
||||
|
||||
@@ -124,7 +123,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertState.Annotations = randomMapOfStrings()
|
||||
alertState.Image = &ngModels.Image{Token: "test_token"}
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
expected := make(models.LabelSet, len(alertState.Annotations)+1)
|
||||
for k, v := range alertState.Annotations {
|
||||
@@ -139,7 +138,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
t.Run("should add state reason annotation if not empty", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.StateReason = "TEST_STATE_REASON"
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, alertState.StateReason, result.Annotations[ngModels.StateReasonAnnotation])
|
||||
})
|
||||
|
||||
@@ -151,7 +150,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertName := util.GenerateShortUID()
|
||||
alertState.Labels[model.AlertNameLabel] = alertName
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
expected := make(models.LabelSet, len(alertState.Labels)+1)
|
||||
for k, v := range alertState.Labels {
|
||||
@@ -167,7 +166,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertState.Labels = randomMapOfStrings()
|
||||
delete(alertState.Labels, model.AlertNameLabel)
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
require.Equal(t, NoDataAlertName, result.Labels[model.AlertNameLabel])
|
||||
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
|
||||
@@ -180,7 +179,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertName := util.GenerateShortUID()
|
||||
alertState.Labels[model.AlertNameLabel] = alertName
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
expected := make(models.LabelSet, len(alertState.Labels)+1)
|
||||
for k, v := range alertState.Labels {
|
||||
@@ -196,7 +195,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
alertState.Labels = randomMapOfStrings()
|
||||
delete(alertState.Labels, model.AlertNameLabel)
|
||||
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
|
||||
require.Equal(t, ErrorAlertName, result.Labels[model.AlertNameLabel])
|
||||
require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename)
|
||||
@@ -206,7 +205,7 @@ func Test_stateToPostableAlert(t *testing.T) {
|
||||
t.Run("should copy labels as is", func(t *testing.T) {
|
||||
alertState := randomState(tc.state)
|
||||
alertState.Labels = randomMapOfStrings()
|
||||
result := stateToPostableAlert(alertState, appURL)
|
||||
result := StateToPostableAlert(alertState, appURL)
|
||||
require.Equal(t, models.LabelSet(alertState.Labels), result.Labels)
|
||||
})
|
||||
}
|
||||
@@ -222,10 +221,10 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
|
||||
}
|
||||
|
||||
evalStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.Error, eval.NoData}
|
||||
states := make([]state.StateTransition, 0, len(evalStates)*len(evalStates))
|
||||
states := make([]StateTransition, 0, len(evalStates)*len(evalStates))
|
||||
for _, to := range evalStates {
|
||||
for _, from := range evalStates {
|
||||
states = append(states, state.StateTransition{
|
||||
states = append(states, StateTransition{
|
||||
State: randomState(to),
|
||||
PreviousState: from,
|
||||
})
|
||||
@@ -240,7 +239,7 @@ func Test_FromAlertsStateToStoppedAlert(t *testing.T) {
|
||||
if !(s.PreviousState == eval.Alerting || s.PreviousState == eval.Error || s.PreviousState == eval.NoData) {
|
||||
continue
|
||||
}
|
||||
alert := stateToPostableAlert(s.State, appURL)
|
||||
alert := StateToPostableAlert(s.State, appURL)
|
||||
alert.EndsAt = strfmt.DateTime(clk.Now())
|
||||
expected = append(expected, *alert)
|
||||
}
|
||||
@@ -271,8 +270,8 @@ func randomTimeInPast() time.Time {
|
||||
return time.Now().Add(-randomDuration())
|
||||
}
|
||||
|
||||
func randomState(evalState eval.State) *state.State {
|
||||
return &state.State{
|
||||
func randomState(evalState eval.State) *State {
|
||||
return &State{
|
||||
State: evalState,
|
||||
AlertRuleUID: util.GenerateShortUID(),
|
||||
StartsAt: time.Now(),
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
prometheusModel "github.com/prometheus/common/model"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -397,3 +399,17 @@ func FormatStateAndReason(state eval.State, reason string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetRuleExtraLabels returns a map of built-in labels that should be added to an alert before it is sent to the Alertmanager or its state is cached.
|
||||
func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolder bool) map[string]string {
|
||||
extraLabels := make(map[string]string, 4)
|
||||
|
||||
extraLabels[alertingModels.NamespaceUIDLabel] = rule.NamespaceUID
|
||||
extraLabels[prometheusModel.AlertNameLabel] = rule.Title
|
||||
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
|
||||
|
||||
if includeFolder {
|
||||
extraLabels[models.FolderTitleLabel] = folderTitle
|
||||
}
|
||||
return extraLabels
|
||||
}
|
||||
|
||||
@@ -2119,237 +2119,6 @@ func TestIntegrationEval(t *testing.T) {
|
||||
expectedStatusCode func() int
|
||||
expectedResponse func() string
|
||||
expectedMessage func() string
|
||||
}{
|
||||
{
|
||||
desc: "alerting condition",
|
||||
payload: `
|
||||
{
|
||||
"grafana_condition": {
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid":"__expr__",
|
||||
"model": {
|
||||
"type":"math",
|
||||
"expression":"1 < 2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"now": "2021-04-11T14:38:14Z"
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedMessage: func() string { return "" },
|
||||
expectedStatusCode: func() int { return http.StatusOK },
|
||||
expectedResponse: func() string {
|
||||
return `{
|
||||
"instances": [
|
||||
{
|
||||
"schema": {
|
||||
"name": "evaluation results",
|
||||
"fields": [
|
||||
{
|
||||
"name": "State",
|
||||
"type": "string",
|
||||
"typeInfo": {
|
||||
"frame": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Info",
|
||||
"type": "string",
|
||||
"typeInfo": {
|
||||
"frame": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"values": [
|
||||
[
|
||||
"Alerting"
|
||||
],
|
||||
[
|
||||
"[ var='A' labels={} value=1 ]"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "normal condition",
|
||||
payload: `
|
||||
{
|
||||
"grafana_condition": {
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"type":"math",
|
||||
"expression":"1 > 2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"now": "2021-04-11T14:38:14Z"
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedMessage: func() string { return "" },
|
||||
expectedStatusCode: func() int { return http.StatusOK },
|
||||
expectedResponse: func() string {
|
||||
return `{
|
||||
"instances": [
|
||||
{
|
||||
"schema": {
|
||||
"name": "evaluation results",
|
||||
"fields": [
|
||||
{
|
||||
"name": "State",
|
||||
"type": "string",
|
||||
"typeInfo": {
|
||||
"frame": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Info",
|
||||
"type": "string",
|
||||
"typeInfo": {
|
||||
"frame": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"values": [
|
||||
[
|
||||
"Normal"
|
||||
],
|
||||
[
|
||||
"[ var='A' labels={} value=0 ]"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "condition not found in any query or expression",
|
||||
payload: `
|
||||
{
|
||||
"grafana_condition": {
|
||||
"condition": "B",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"type":"math",
|
||||
"expression":"1 > 2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"now": "2021-04-11T14:38:14Z"
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedStatusCode: func() int { return http.StatusBadRequest },
|
||||
expectedMessage: func() string {
|
||||
return "invalid condition: condition B does not exist, must be one of [A]"
|
||||
},
|
||||
expectedResponse: func() string { return "" },
|
||||
},
|
||||
{
|
||||
desc: "unknown query datasource",
|
||||
payload: `
|
||||
{
|
||||
"grafana_condition": {
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "unknown",
|
||||
"model": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"now": "2021-04-11T14:38:14Z"
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedStatusCode: func() int {
|
||||
if setting.IsEnterprise {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
},
|
||||
expectedMessage: func() string {
|
||||
if setting.IsEnterprise {
|
||||
return "user is not authorized to query one or many data sources used by the rule"
|
||||
}
|
||||
return "invalid condition: failed to build query 'A': data source not found"
|
||||
},
|
||||
expectedResponse: func() string { return "" },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
u := fmt.Sprintf("http://grafana:password@%s/api/v1/rule/test/grafana", grafanaListedAddr)
|
||||
r := strings.NewReader(tc.payload)
|
||||
// nolint:gosec
|
||||
resp, err := http.Post(u, "application/json", r)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
res := Response{}
|
||||
err = json.Unmarshal(b, &res)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode)
|
||||
if tc.expectedResponse() != "" {
|
||||
require.JSONEq(t, tc.expectedResponse(), string(b))
|
||||
}
|
||||
if tc.expectedMessage() != "" {
|
||||
assert.Equal(t, tc.expectedMessage(), res.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// test eval queries and expressions
|
||||
testCases = []struct {
|
||||
desc string
|
||||
payload string
|
||||
expectedStatusCode func() int
|
||||
expectedResponse func() string
|
||||
expectedMessage func() string
|
||||
}{
|
||||
{
|
||||
desc: "alerting condition",
|
||||
|
||||
408
pkg/tests/api/alerting/api_testing_test.go
Normal file
408
pkg/tests/api/alerting/api_testing_test.go
Normal 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"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,3 +334,22 @@ func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.Backt
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableExtendedRuleNodeExtended) (int, string) {
|
||||
t.Helper()
|
||||
buf := bytes.Buffer{}
|
||||
enc := json.NewEncoder(&buf)
|
||||
err := enc.Encode(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
u := fmt.Sprintf("%s/api/v1/rule/test/grafana", a.url)
|
||||
// nolint:gosec
|
||||
resp, err := http.Post(u, "application/json", &buf)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user