diff --git a/go.mod b/go.mod index c9d423ef3c8..fe506135986 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.5.0 - github.com/grafana/alerting v0.0.0-20230426193323-4f09f516596b + github.com/grafana/alerting v0.0.0-20230428095912-33c5aa68a5ba github.com/grafana/grafana-aws-sdk v0.12.0 github.com/grafana/grafana-azure-sdk-go v1.6.0 github.com/grafana/grafana-plugin-sdk-go v0.160.0 diff --git a/go.sum b/go.sum index 1443b20fec4..d60b20df894 100644 --- a/go.sum +++ b/go.sum @@ -1275,8 +1275,12 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20230426193323-4f09f516596b h1:dAHlNtW62HcSh+XmhV7kmcWqnOSnYsw/pGHiEyix544= -github.com/grafana/alerting v0.0.0-20230426193323-4f09f516596b/go.mod h1:5edgy6tQY4+W2wuJdi8g2GjbVmpJufguy7QGIRl2q4o= +github.com/grafana/alerting v0.0.0-20230427200804-f8565cd8b74b h1:SyatotlHGK+05AxICctry09ZEawhE0AasWpOifljH4M= +github.com/grafana/alerting v0.0.0-20230427200804-f8565cd8b74b/go.mod h1:nHfrSTdV7/l74N5/ezqlQ+JwSvIChhN3G5+PjCfwG/E= +github.com/grafana/alerting v0.0.0-20230428094731-e067f119be06 h1:g+J4UGslzpi9Kt+846NDAjGtVyt8K9Lgjj/vUh61yK0= +github.com/grafana/alerting v0.0.0-20230428094731-e067f119be06/go.mod h1:5edgy6tQY4+W2wuJdi8g2GjbVmpJufguy7QGIRl2q4o= +github.com/grafana/alerting v0.0.0-20230428095912-33c5aa68a5ba h1:aNTu22ojw4XY24DYNAuvw8v/5iFUNk2bkdTeeWQ5+0o= +github.com/grafana/alerting v0.0.0-20230428095912-33c5aa68a5ba/go.mod h1:5edgy6tQY4+W2wuJdi8g2GjbVmpJufguy7QGIRl2q4o= github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cuetsy v0.1.8 h1:l0AKXfHr0clu6qPirirDzNC/W5mqq5gG7iruOVolG34= diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 1c6e54fa257..08fedec5108 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -52,6 +52,7 @@ type Alertmanager interface { // Receivers GetReceivers(ctx context.Context) []apimodels.Receiver TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*notifier.TestReceiversResult, error) + TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) } type AlertingStore interface { diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 3c3c18b6fd0..b61469f97b6 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -342,6 +342,20 @@ func (srv AlertmanagerSrv) RoutePostTestReceivers(c *contextmodel.ReqContext, bo return response.JSON(statusForTestReceivers(result.Receivers), newTestReceiversResult(result)) } +func (srv AlertmanagerSrv) RoutePostTestTemplates(c *contextmodel.ReqContext, body apimodels.TestTemplatesConfigBodyParams) response.Response { + am, errResp := srv.AlertmanagerFor(c.OrgID) + if errResp != nil { + return errResp + } + + res, err := am.TestTemplate(c.Req.Context(), body) + if err != nil { + return response.Error(http.StatusInternalServerError, "", err) + } + + return response.JSON(http.StatusOK, newTestTemplateResult(res)) +} + // contextWithTimeoutFromRequest returns a context with a deadline set from the // Request-Timeout header in the HTTP request. If the header is absent then the // context will use the default timeout. The timeout in the Request-Timeout @@ -433,6 +447,24 @@ func statusForTestReceivers(v []notifier.TestReceiverResult) int { } } +func newTestTemplateResult(res *notifier.TestTemplatesResults) apimodels.TestTemplatesResults { + apiRes := apimodels.TestTemplatesResults{} + for _, r := range res.Results { + apiRes.Results = append(apiRes.Results, apimodels.TestTemplatesResult{ + Name: r.Name, + Text: r.Text, + }) + } + for _, e := range res.Errors { + apiRes.Errors = append(apiRes.Errors, apimodels.TestTemplatesErrorResult{ + Name: e.Name, + Kind: apimodels.TemplateErrorKind(e.Kind), + Message: e.Error.Error(), + }) + } + return apiRes +} + func (srv AlertmanagerSrv) AlertmanagerFor(orgID int64) (Alertmanager, *response.NormalResponse) { am, err := srv.mam.AlertmanagerFor(orgID) if err == nil { diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 94c29dfbf2d..25bea2612f8 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -421,6 +421,46 @@ func TestRoutePostGrafanaAlertingConfigHistoryActivate(t *testing.T) { }) } +func TestRoutePostTestTemplates(t *testing.T) { + sut := createSut(t, nil) + + t.Run("assert 404 when no alertmanager found", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(10) + + response := sut.RoutePostTestTemplates(rc, apimodels.TestTemplatesConfigBodyParams{}) + require.Equal(tt, 404, response.Status()) + }) + + t.Run("assert 409 when alertmanager not ready", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(3) + + response := sut.RoutePostTestTemplates(rc, apimodels.TestTemplatesConfigBodyParams{}) + require.Equal(tt, 409, response.Status()) + }) + + t.Run("assert 200 for a valid alertmanager", func(tt *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(tt, err) + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + + rc := createRequestCtxInOrg(1) + + response := sut.RoutePostTestTemplates(rc, apimodels.TestTemplatesConfigBodyParams{}) + require.Equal(tt, 200, response.Status()) + }) +} + func TestSilenceCreate(t *testing.T) { makeSilence := func(comment string, createdBy string, startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 418b5692634..865cc96a418 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -173,6 +173,9 @@ func (api *API) authorize(method, path string) web.Handler { case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/receivers/test": fallback = middleware.ReqEditorRole eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) + case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/templates/test": + fallback = middleware.ReqSignedIn + eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) // External Alertmanager Paths case http.MethodDelete + "/api/alertmanager/{DatasourceUID}/config/api/v1/alerts": diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 528644021af..05848122d13 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 47) + require.Len(t, paths, 48) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index 78b87ded9a0..39be2570e4f 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -189,3 +189,7 @@ func (f *AlertmanagerApiHandler) handleRouteGetGrafanaReceivers(ctx *contextmode func (f *AlertmanagerApiHandler) handleRoutePostTestGrafanaReceivers(ctx *contextmodel.ReqContext, conf apimodels.TestReceiversConfigBodyParams) response.Response { return f.GrafanaSvc.RoutePostTestReceivers(ctx, conf) } + +func (f *AlertmanagerApiHandler) handleRoutePostTestGrafanaTemplates(ctx *contextmodel.ReqContext, conf apimodels.TestTemplatesConfigBodyParams) response.Response { + return f.GrafanaSvc.RoutePostTestTemplates(ctx, conf) +} diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index 145b0c33ea4..244ad5fbba2 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -44,6 +44,7 @@ type AlertmanagerApi interface { RoutePostGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response RoutePostGrafanaAlertingConfigHistoryActivate(*contextmodel.ReqContext) response.Response RoutePostTestGrafanaReceivers(*contextmodel.ReqContext) response.Response + RoutePostTestGrafanaTemplates(*contextmodel.ReqContext) response.Response } func (f *AlertmanagerApiHandler) RouteCreateGrafanaSilence(ctx *contextmodel.ReqContext) response.Response { @@ -181,6 +182,14 @@ func (f *AlertmanagerApiHandler) RoutePostTestGrafanaReceivers(ctx *contextmodel } return f.handleRoutePostTestGrafanaReceivers(ctx, conf) } +func (f *AlertmanagerApiHandler) RoutePostTestGrafanaTemplates(ctx *contextmodel.ReqContext) response.Response { + // Parse Request Body + conf := apimodels.TestTemplatesConfigBodyParams{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.handleRoutePostTestGrafanaTemplates(ctx, conf) +} func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApi, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { @@ -434,5 +443,15 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApi, m *metrics m, ), ) + group.Post( + toMacaronPath("/api/alertmanager/grafana/config/api/v1/templates/test"), + api.authorize(http.MethodPost, "/api/alertmanager/grafana/config/api/v1/templates/test"), + metrics.Instrument( + http.MethodPost, + "/api/alertmanager/grafana/config/api/v1/templates/test", + api.Hooks.Wrap(srv.RoutePostTestGrafanaTemplates), + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index d989685b0b8..bdb1ac22de1 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -299,6 +299,10 @@ "AlertingRule": { "description": "adapted from cortex", "properties": { + "activeAt": { + "format": "date-time", + "type": "string" + }, "alerts": { "items": { "$ref": "#/definitions/Alert" @@ -339,6 +343,20 @@ "description": "State can be \"pending\", \"firing\", \"inactive\".", "type": "string" }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" + }, + "totalsFiltered": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" + }, "type": { "$ref": "#/definitions/RuleType" } @@ -350,7 +368,7 @@ "type", "state", "annotations", - "alerts" + "activeAt" ], "type": "object" }, @@ -2845,6 +2863,13 @@ "$ref": "#/definitions/RuleGroup" }, "type": "array" + }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" } }, "required": [ @@ -2878,6 +2903,13 @@ "$ref": "#/definitions/AlertingRule" }, "type": "array" + }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" } }, "required": [ @@ -3343,6 +3375,77 @@ }, "type": "object" }, + "TestTemplatesConfigBodyParams": { + "properties": { + "alerts": { + "description": "Alerts to use as data when testing the template.", + "items": { + "$ref": "#/definitions/postableAlert" + }, + "type": "array" + }, + "name": { + "description": "Name of the template file.", + "type": "string" + }, + "template": { + "description": "Template string to test.", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesErrorResult": { + "properties": { + "kind": { + "description": "Kind of template error that occurred.", + "enum": [ + "invalid_template", + "execution_error" + ], + "type": "string" + }, + "message": { + "description": "Error message.", + "type": "string" + }, + "name": { + "description": "Name of the associated template for this error. Will be empty if the Kind is \"invalid_template\".", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesResult": { + "properties": { + "name": { + "description": "Name of the associated template definition for this result.", + "type": "string" + }, + "text": { + "description": "Interpolated value of the template.", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesResults": { + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/TestTemplatesErrorResult" + }, + "type": "array" + }, + "results": { + "items": { + "$ref": "#/definitions/TestTemplatesResult" + }, + "type": "array" + } + }, + "type": "object" + }, "Threshold": { "description": "Threshold a single step on the threshold list", "properties": { @@ -3644,7 +3747,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3829,13 +3931,13 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3891,7 +3993,6 @@ "type": "array" }, "integration": { - "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4035,7 +4136,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4073,6 +4173,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 720eb99cf4c..b9d06f56419 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -168,6 +168,19 @@ import ( // 408: Failure // 409: AlertManagerNotReady +// swagger:route POST /api/alertmanager/grafana/config/api/v1/templates/test alertmanager RoutePostTestGrafanaTemplates +// +// Test Grafana managed templates without saving them. +// Produces: +// - application/json +// +// Responses: +// +// 200: TestTemplatesResults +// 400: ValidationError +// 403: PermissionDenied +// 409: AlertManagerNotReady + // swagger:route GET /api/alertmanager/grafana/api/v2/silences alertmanager RouteGetGrafanaSilences // // get silences @@ -293,6 +306,56 @@ type TestReceiverConfigResult struct { Error string `json:"error,omitempty"` } +// swagger:parameters RoutePostTestGrafanaTemplates +type TestTemplatesConfigParams struct { + // in:body + Body TestTemplatesConfigBodyParams +} + +type TestTemplatesConfigBodyParams struct { + // Alerts to use as data when testing the template. + Alerts []*amv2.PostableAlert `json:"alerts"` + + // Template string to test. + Template string `json:"template"` + + // Name of the template file. + Name string `json:"name"` +} + +// swagger:model +type TestTemplatesResults struct { + Results []TestTemplatesResult `json:"results,omitempty"` + Errors []TestTemplatesErrorResult `json:"errors,omitempty"` +} + +type TestTemplatesResult struct { + // Name of the associated template definition for this result. + Name string `json:"name"` + + // Interpolated value of the template. + Text string `json:"text"` +} + +type TestTemplatesErrorResult struct { + // Name of the associated template for this error. Will be empty if the Kind is "invalid_template". + Name string `json:"name,omitempty"` + + // Kind of template error that occurred. + Kind TemplateErrorKind `json:"kind"` + + // Error message. + Message string `json:"message"` +} + +// swagger:enum TemplateErrorKind +type TemplateErrorKind string + +const ( + InvalidTemplate TemplateErrorKind = "invalid_template" + ExecutionError TemplateErrorKind = "execution_error" +) + // swagger:parameters RouteCreateSilence RouteCreateGrafanaSilence type CreateSilenceParams struct { // in:body diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index a4f647fe6b0..0314e936ba8 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -299,6 +299,10 @@ "AlertingRule": { "description": "adapted from cortex", "properties": { + "activeAt": { + "format": "date-time", + "type": "string" + }, "alerts": { "items": { "$ref": "#/definitions/Alert" @@ -339,6 +343,20 @@ "description": "State can be \"pending\", \"firing\", \"inactive\".", "type": "string" }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" + }, + "totalsFiltered": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" + }, "type": { "$ref": "#/definitions/RuleType" } @@ -350,7 +368,7 @@ "type", "state", "annotations", - "alerts" + "activeAt" ], "type": "object" }, @@ -2845,6 +2863,13 @@ "$ref": "#/definitions/RuleGroup" }, "type": "array" + }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" } }, "required": [ @@ -2878,6 +2903,13 @@ "$ref": "#/definitions/AlertingRule" }, "type": "array" + }, + "totals": { + "additionalProperties": { + "format": "int64", + "type": "integer" + }, + "type": "object" } }, "required": [ @@ -3343,6 +3375,77 @@ }, "type": "object" }, + "TestTemplatesConfigBodyParams": { + "properties": { + "alerts": { + "description": "Alerts to use as data when testing the template.", + "items": { + "$ref": "#/definitions/postableAlert" + }, + "type": "array" + }, + "name": { + "description": "Name of the template file.", + "type": "string" + }, + "template": { + "description": "Template string to test.", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesErrorResult": { + "properties": { + "kind": { + "description": "Kind of template error that occurred.", + "enum": [ + "invalid_template", + "execution_error" + ], + "type": "string" + }, + "message": { + "description": "Error message.", + "type": "string" + }, + "name": { + "description": "Name of the associated template for this error. Will be empty if the Kind is \"invalid_template\".", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesResult": { + "properties": { + "name": { + "description": "Name of the associated template definition for this result.", + "type": "string" + }, + "text": { + "description": "Interpolated value of the template.", + "type": "string" + } + }, + "type": "object" + }, + "TestTemplatesResults": { + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/TestTemplatesErrorResult" + }, + "type": "array" + }, + "results": { + "items": { + "$ref": "#/definitions/TestTemplatesResult" + }, + "type": "array" + } + }, + "type": "object" + }, "Threshold": { "description": "Threshold a single step on the threshold list", "properties": { @@ -3668,6 +3771,7 @@ "type": "object" }, "alertGroups": { + "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3888,6 +3992,7 @@ "type": "array" }, "integration": { + "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4031,7 +4136,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4606,6 +4710,53 @@ ] } }, + "/api/alertmanager/grafana/config/api/v1/templates/test": { + "post": { + "operationId": "RoutePostTestGrafanaTemplates", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/TestTemplatesConfigBodyParams" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "TestTemplatesResults", + "schema": { + "$ref": "#/definitions/TestTemplatesResults" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + }, + "409": { + "description": "AlertManagerNotReady", + "schema": { + "$ref": "#/definitions/AlertManagerNotReady" + } + } + }, + "summary": "Test Grafana managed templates without saving them.", + "tags": [ + "alertmanager" + ] + } + }, "/api/alertmanager/grafana/config/history": { "get": { "description": "gets Alerting configurations that were successfully applied in the past", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 65794ac1b02..0d51eafecd2 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -435,6 +435,53 @@ } } }, + "/api/alertmanager/grafana/config/api/v1/templates/test": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "alertmanager" + ], + "summary": "Test Grafana managed templates without saving them.", + "operationId": "RoutePostTestGrafanaTemplates", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/TestTemplatesConfigBodyParams" + } + } + ], + "responses": { + "200": { + "description": "TestTemplatesResults", + "schema": { + "$ref": "#/definitions/TestTemplatesResults" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + }, + "409": { + "description": "AlertManagerNotReady", + "schema": { + "$ref": "#/definitions/AlertManagerNotReady" + } + } + } + } + }, "/api/alertmanager/grafana/config/history": { "get": { "description": "gets Alerting configurations that were successfully applied in the past", @@ -3016,9 +3063,13 @@ "type", "state", "annotations", - "alerts" + "activeAt" ], "properties": { + "activeAt": { + "type": "string", + "format": "date-time" + }, "alerts": { "type": "array", "items": { @@ -3059,6 +3110,20 @@ "description": "State can be \"pending\", \"firing\", \"inactive\".", "type": "string" }, + "totals": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "totalsFiltered": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, "type": { "$ref": "#/definitions/RuleType" } @@ -5563,6 +5628,13 @@ "items": { "$ref": "#/definitions/RuleGroup" } + }, + "totals": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } } } }, @@ -5599,6 +5671,13 @@ "items": { "$ref": "#/definitions/AlertingRule" } + }, + "totals": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } } } }, @@ -6057,6 +6136,77 @@ } } }, + "TestTemplatesConfigBodyParams": { + "type": "object", + "properties": { + "alerts": { + "description": "Alerts to use as data when testing the template.", + "type": "array", + "items": { + "$ref": "#/definitions/postableAlert" + } + }, + "name": { + "description": "Name of the template file.", + "type": "string" + }, + "template": { + "description": "Template string to test.", + "type": "string" + } + } + }, + "TestTemplatesErrorResult": { + "type": "object", + "properties": { + "kind": { + "description": "Kind of template error that occurred.", + "type": "string", + "enum": [ + "invalid_template", + "execution_error" + ] + }, + "message": { + "description": "Error message.", + "type": "string" + }, + "name": { + "description": "Name of the associated template for this error. Will be empty if the Kind is \"invalid_template\".", + "type": "string" + } + } + }, + "TestTemplatesResult": { + "type": "object", + "properties": { + "name": { + "description": "Name of the associated template definition for this result.", + "type": "string" + }, + "text": { + "description": "Interpolated value of the template.", + "type": "string" + } + } + }, + "TestTemplatesResults": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/TestTemplatesErrorResult" + } + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/TestTemplatesResult" + } + } + } + }, "Threshold": { "description": "Threshold a single step on the threshold list", "type": "object", @@ -6383,6 +6533,7 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -6608,6 +6759,7 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { + "description": "Integration integration", "type": "object", "required": [ "name", @@ -6752,7 +6904,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index e5304520110..8a3090e25e5 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -117,7 +117,8 @@ func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A } amcfg := &alertingNotify.GrafanaAlertmanagerConfig{ - WorkingDirectory: workingDir, + WorkingDirectory: filepath.Join(cfg.DataPath, workingDir, strconv.Itoa(int(orgID))), + ExternalURL: cfg.AppURL, AlertStoreCallback: nil, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, Silences: silencesOptions, @@ -259,9 +260,10 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig cfg.TemplateFiles = map[string]string{} } cfg.TemplateFiles["__default__.tmpl"] = alertingTemplates.DefaultTemplateString + cfg.AlertmanagerConfig.Templates = append(cfg.AlertmanagerConfig.Templates, "__default__.tmpl") // next, we need to make sure we persist the templates to disk. - paths, templatesChanged, err := PersistTemplates(cfg, am.WorkingDirPath()) + _, templatesChanged, err := PersistTemplates(cfg, am.Base.WorkingDirectory()) if err != nil { return false, err } @@ -272,18 +274,11 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig return false, nil } - // With the templates persisted, create the template list using the paths. - tmpl, err := am.Base.TemplateFromPaths(am.Settings.AppURL, paths...) - if err != nil { - return false, err - } - err = am.Base.ApplyConfig(AlertingConfiguration{ - RawAlertmanagerConfig: rawConfig, - AlertmanagerConfig: cfg.AlertmanagerConfig, - AlertmanagerTemplates: tmpl, - IntegrationsFunc: am.buildIntegrationsMap, - ReceiverIntegrationsFunc: am.buildReceiverIntegration, + rawAlertmanagerConfig: rawConfig, + alertmanagerConfig: cfg.AlertmanagerConfig, + receivers: PostableApiAlertingConfigToApiReceivers(cfg.AlertmanagerConfig), + receiverIntegrationsFunc: am.buildReceiverIntegrations, }) if err != nil { return false, err @@ -310,22 +305,8 @@ func (am *Alertmanager) applyAndMarkConfig(ctx context.Context, hash string, cfg return nil } -func (am *Alertmanager) WorkingDirPath() string { - return filepath.Join(am.Settings.DataPath, workingDir, strconv.Itoa(int(am.orgID))) -} - -// buildIntegrationsMap builds a map of name to the list of Grafana integration notifiers off of a list of receiver config. -func (am *Alertmanager) buildIntegrationsMap(receivers []*alertingNotify.APIReceiver, templates *alertingTemplates.Template) (map[string][]*alertingNotify.Integration, error) { - integrationsMap := make(map[string][]*alertingNotify.Integration, len(receivers)) - for _, receiver := range receivers { - integrations, err := am.buildReceiverIntegrations(receiver, templates) - if err != nil { - return nil, err - } - integrationsMap[receiver.Name] = integrations - } - - return integrationsMap, nil +func (am *Alertmanager) AppURL() string { + return am.Settings.AppURL } // buildReceiverIntegrations builds a list of integration notifiers off of a receiver config. @@ -356,23 +337,6 @@ func (am *Alertmanager) buildReceiverIntegrations(receiver *alertingNotify.APIRe return integrations, nil } -func (am *Alertmanager) buildReceiverIntegration(r *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (*alertingNotify.Integration, error) { - apiReceiver := &alertingNotify.APIReceiver{ - GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ - Integrations: []*alertingNotify.GrafanaIntegrationConfig{r}, - }, - } - integrations, err := am.buildReceiverIntegrations(apiReceiver, tmpl) - if err != nil { - return nil, err - } - if len(integrations) == 0 { - // This should not happen, but it is better to return some error rather than having a panic. - return nil, fmt.Errorf("failed to build integration") - } - return integrations[0], nil -} - // PutAlerts receives the alerts and then sends them through the corresponding route based on whenever the alert has a receiver embedded or not func (am *Alertmanager) PutAlerts(postableAlerts apimodels.PostableAlerts) error { alerts := make(alertingNotify.PostableAlerts, 0, len(postableAlerts.PostableAlerts)) diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index b2a0408745f..d22f51069e4 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -22,6 +22,7 @@ func setupAMTest(t *testing.T) *Alertmanager { dir := t.TempDir() cfg := &setting.Cfg{ DataPath: dir, + AppURL: "http://localhost:9093", } m := metrics.NewAlertmanagerMetrics(prometheus.NewRegistry()) diff --git a/pkg/services/ngalert/notifier/config.go b/pkg/services/ngalert/notifier/config.go index 5acb73c390d..10fb03e47ce 100644 --- a/pkg/services/ngalert/notifier/config.go +++ b/pkg/services/ngalert/notifier/config.go @@ -92,18 +92,16 @@ func Load(rawConfig []byte) (*api.PostableUserConfig, error) { // AlertingConfiguration provides configuration for an Alertmanager. // It implements the notify.Configuration interface. type AlertingConfiguration struct { - AlertmanagerConfig api.PostableApiAlertingConfig - RawAlertmanagerConfig []byte + alertmanagerConfig api.PostableApiAlertingConfig + rawAlertmanagerConfig []byte - AlertmanagerTemplates *alertingTemplates.Template - - IntegrationsFunc func(receivers []*alertingNotify.APIReceiver, templates *alertingTemplates.Template) (map[string][]*alertingNotify.Integration, error) - ReceiverIntegrationsFunc func(r *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (*alertingNotify.Integration, error) + receivers []*alertingNotify.APIReceiver + receiverIntegrationsFunc func(r *alertingNotify.APIReceiver, tmpl *alertingTemplates.Template) ([]*alertingNotify.Integration, error) } -func (a AlertingConfiguration) BuildReceiverIntegrationsFunc() func(next *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (alertingNotify.Notifier, error) { - return func(next *alertingNotify.GrafanaIntegrationConfig, tmpl *alertingTemplates.Template) (alertingNotify.Notifier, error) { - return a.ReceiverIntegrationsFunc(next, tmpl) +func (a AlertingConfiguration) BuildReceiverIntegrationsFunc() func(next *alertingNotify.APIReceiver, tmpl *alertingTemplates.Template) ([]*alertingNotify.Integration, error) { + return func(next *alertingNotify.APIReceiver, tmpl *alertingTemplates.Template) ([]*alertingNotify.Integration, error) { + return a.receiverIntegrationsFunc(next, tmpl) } } @@ -112,29 +110,29 @@ func (a AlertingConfiguration) DispatcherLimits() alertingNotify.DispatcherLimit } func (a AlertingConfiguration) InhibitRules() []alertingNotify.InhibitRule { - return a.AlertmanagerConfig.InhibitRules + return a.alertmanagerConfig.InhibitRules } func (a AlertingConfiguration) MuteTimeIntervals() []alertingNotify.MuteTimeInterval { - return a.AlertmanagerConfig.MuteTimeIntervals + return a.alertmanagerConfig.MuteTimeIntervals } -func (a AlertingConfiguration) ReceiverIntegrations() (map[string][]*alertingNotify.Integration, error) { - return a.IntegrationsFunc(PostableApiAlertingConfigToApiReceivers(a.AlertmanagerConfig), a.AlertmanagerTemplates) +func (a AlertingConfiguration) Receivers() []*alertingNotify.APIReceiver { + return a.receivers } func (a AlertingConfiguration) RoutingTree() *alertingNotify.Route { - return a.AlertmanagerConfig.Route.AsAMRoute() + return a.alertmanagerConfig.Route.AsAMRoute() } -func (a AlertingConfiguration) Templates() *alertingTemplates.Template { - return a.AlertmanagerTemplates +func (a AlertingConfiguration) Templates() []string { + return a.alertmanagerConfig.Templates } func (a AlertingConfiguration) Hash() [16]byte { - return md5.Sum(a.RawAlertmanagerConfig) + return md5.Sum(a.rawAlertmanagerConfig) } func (a AlertingConfiguration) Raw() []byte { - return a.RawAlertmanagerConfig + return a.rawAlertmanagerConfig } diff --git a/pkg/services/ngalert/notifier/templates.go b/pkg/services/ngalert/notifier/templates.go new file mode 100644 index 00000000000..0f34a7e60bd --- /dev/null +++ b/pkg/services/ngalert/notifier/templates.go @@ -0,0 +1,63 @@ +package notifier + +import ( + "context" + + alertingModels "github.com/grafana/alerting/models" + alertingNotify "github.com/grafana/alerting/notify" + amv2 "github.com/prometheus/alertmanager/api/v2/models" + prometheusModel "github.com/prometheus/common/model" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +type TestTemplatesResults = alertingNotify.TestTemplatesResults + +var ( + DefaultLabels = map[string]string{ + prometheusModel.AlertNameLabel: `alert title`, + alertingModels.FolderTitleLabel: `folder title`, + } + DefaultAnnotations = map[string]string{ + alertingModels.ValuesAnnotation: `{"B":22,"C":1}`, + alertingModels.ValueStringAnnotation: `[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]`, + alertingModels.OrgIDAnnotation: `1`, + alertingModels.DashboardUIDAnnotation: `dashboard_uid`, + alertingModels.PanelIDAnnotation: `1`, + } +) + +// TestTemplate tests the given template string against the given alerts. Existing templates are used to provide context for the test. +// If an existing template of the same filename as the one being tested is found, it will not be used as context. +func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*TestTemplatesResults, error) { + for _, alert := range c.Alerts { + addDefaultLabelsAndAnnotations(alert) + } + + return am.Base.TestTemplate(ctx, alertingNotify.TestTemplatesConfigBodyParams{ + Alerts: c.Alerts, + Template: c.Template, + Name: c.Name, + }) +} + +// addDefaultLabelsAndAnnotations is a slimmed down version of schedule.stateToPostableAlert and schedule.getRuleExtraLabels using default values. +func addDefaultLabelsAndAnnotations(alert *amv2.PostableAlert) { + if alert.Labels == nil { + alert.Labels = make(map[string]string) + } + for k, v := range DefaultLabels { + if _, ok := alert.Labels[k]; !ok { + alert.Labels[k] = v + } + } + + if alert.Annotations == nil { + alert.Annotations = make(map[string]string) + } + for k, v := range DefaultAnnotations { + if _, ok := alert.Annotations[k]; !ok { + alert.Annotations[k] = v + } + } +} diff --git a/pkg/services/ngalert/notifier/templates_test.go b/pkg/services/ngalert/notifier/templates_test.go new file mode 100644 index 00000000000..a5806405642 --- /dev/null +++ b/pkg/services/ngalert/notifier/templates_test.go @@ -0,0 +1,174 @@ +package notifier + +import ( + "context" + "fmt" + "testing" + + "github.com/go-openapi/strfmt" + alertingModels "github.com/grafana/alerting/models" + alertingNotify "github.com/grafana/alerting/notify" + amv2 "github.com/prometheus/alertmanager/api/v2/models" + prometheusModel "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +var ( + simpleAlert = amv2.PostableAlert{ + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, + }, + Annotations: amv2.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, + StartsAt: strfmt.DateTime{}, + EndsAt: strfmt.DateTime{}, + } +) + +func TestTemplateDefaultData(t *testing.T) { + am := setupAMTest(t) + + tests := []struct { + name string + input apimodels.TestTemplatesConfigBodyParams + expected TestTemplatesResults + }{{ + name: "check various extended data", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{&simpleAlert}, + Name: "slack.title", + Template: `{{ define "slack.title" }} +Receiver: {{ .Receiver }} +Status: {{ .Status }} +ExternalURL: {{ .ExternalURL }} +Alerts: {{ len .Alerts }} +Firing Alerts: {{ len .Alerts.Firing }} +Resolved Alerts: {{ len .Alerts.Resolved }} +GroupLabels: {{ range .GroupLabels.SortedPairs }}{{ .Name }}={{ .Value }} {{ end }} +CommonLabels: {{ range .CommonLabels.SortedPairs }}{{ .Name }}={{ .Value }} {{ end }} +CommonAnnotations: {{ range .CommonAnnotations.SortedPairs }}{{ .Name }}={{ .Value }} {{ end }} +{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=folder title lbl1=val1 \nCommonAnnotations: ann1=annv1 \n", + }}, + Errors: nil, + }, + }, { + name: "AlertNameLabel", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: fmt.Sprintf(`{{ define "slack.title" }}{{ index (index .Alerts 0 ).Labels "%s" }}{{ end }}`, prometheusModel.AlertNameLabel), + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: DefaultLabels[prometheusModel.AlertNameLabel], + }}, + Errors: nil, + }, + }, { + name: "FolderTitleLabel", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: fmt.Sprintf(`{{ define "slack.title" }}{{ index (index .Alerts 0 ).Labels "%s" }}{{ end }}`, alertingModels.FolderTitleLabel), + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: DefaultLabels[alertingModels.FolderTitleLabel], + }}, + Errors: nil, + }, + }, { + name: "ValuesAnnotation", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: `{{ define "slack.title" }}{{ range $key, $value := (index .Alerts 0 ).Values }}{{ $key }}={{ $value }} {{ end }}{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: "B=22 C=1 ", + }}, + Errors: nil, + }, + }, { + name: "ValueStringAnnotation", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).ValueString }}{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: DefaultAnnotations[alertingModels.ValueStringAnnotation], + }}, + Errors: nil, + }, + }, { + name: "DashboardURL generation contains DashboardUIDAnnotation and OrgIDAnnotation ", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).DashboardURL }}{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: fmt.Sprintf("http://localhost:9093/d/%s?orgId=%s", + DefaultAnnotations[alertingModels.DashboardUIDAnnotation], + DefaultAnnotations[alertingModels.OrgIDAnnotation]), + }}, + Errors: nil, + }, + }, { + name: "PanelURL generation contains DashboardUIDAnnotation, PanelIDAnnotation, and OrgIDAnnotation ", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{}}, + Name: "slack.title", + Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).PanelURL }}{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: fmt.Sprintf("http://localhost:9093/d/%s?orgId=%s&viewPanel=%s", + DefaultAnnotations[alertingModels.DashboardUIDAnnotation], + DefaultAnnotations[alertingModels.OrgIDAnnotation], + DefaultAnnotations[alertingModels.PanelIDAnnotation]), + }}, + Errors: nil, + }, + }, { + name: "GeneratorURL generation ", + input: apimodels.TestTemplatesConfigBodyParams{ + Alerts: []*amv2.PostableAlert{{Alert: amv2.Alert{GeneratorURL: "http://localhost:3000"}}}, + Name: "slack.title", + Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).GeneratorURL }}{{ end }}`, + }, + expected: TestTemplatesResults{ + Results: []alertingNotify.TestTemplatesResult{{ + Name: "slack.title", + Text: fmt.Sprintf("http://localhost:3000?orgId=%s", DefaultAnnotations[alertingModels.OrgIDAnnotation]), + }}, + Errors: nil, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := am.TestTemplate(context.Background(), test.input) + require.NoError(t, err) + assert.Equal(t, test.expected, *res) + }) + } +} diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index 9ab9a901f6f..6382a78ca65 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -102,7 +102,7 @@ func TestIntegrationAlertmanagerConfigurationIsTransactional(t *testing.T) { require.NoError(t, err) var res map[string]interface{} require.NoError(t, json.Unmarshal(b, &res)) - require.Regexp(t, `^failed to save and apply Alertmanager configuration: failed to build integration map: failed to validate integration "slack.receiver" \(UID [^\)]+\) of type "slack": token must be specified when using the Slack chat API`, res["message"]) + require.Regexp(t, `^failed to save and apply Alertmanager configuration: failed to validate integration "slack.receiver" \(UID [^\)]+\) of type "slack": token must be specified when using the Slack chat API`, res["message"]) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body))