From f76a5f179671831aa41e7c5cc49ca672e03a1f3e Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Fri, 6 May 2022 03:33:33 -0400 Subject: [PATCH] [v8.5.x] Alerting: Provisioning message templates (#48789) * Alerting: Provisioning message templates (#48665) * Generate API for writing templates * Persist templates app logic layer * Validate templates * Extract logic, make set and delete methods * Drop post route for templates * Fix response details, wire up remainder of API * Authorize routes * Mirror some existing tests on new APIs * Generate mock for prov store * Wire up prov store mock, add tests using it * Cover cases for both storage paths * Add happy path tests and fix bugs if file contains no template section * Normalize template content with define statement * Tests for deletion * Fix linter error * Move provenance field to DTO * empty commit * ID to name * Fix in auth too (cherry picked from commit 0f56462fbeb149c055158fa1ad2c549473b1b884) * ErrorContains -> Error then Contains Co-authored-by: Alexander Weaver --- pkg/services/ngalert/api/api_provisioning.go | 31 +- pkg/services/ngalert/api/authorization.go | 6 +- .../ngalert/api/forked_provisioning.go | 8 + .../api/generated_base_api_provisioning.go | 40 ++- .../definitions/provisioning_templates.go | 75 ++++- .../ngalert/api/tooling/definitions/shared.go | 3 + pkg/services/ngalert/api/tooling/post.json | 58 +++- pkg/services/ngalert/api/tooling/spec.json | 56 +++- pkg/services/ngalert/provisioning/persist.go | 1 + .../provisioning/provisioning_store_mock.go | 208 ++++++++++++ .../ngalert/provisioning/templates.go | 117 ++++++- .../ngalert/provisioning/templates_test.go | 303 +++++++++++++++++- 12 files changed, 880 insertions(+), 26 deletions(-) create mode 100644 pkg/services/ngalert/api/tooling/definitions/shared.go create mode 100644 pkg/services/ngalert/provisioning/provisioning_store_mock.go diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index fadc94642b3..a1b2672ec82 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -32,6 +32,8 @@ type ContactPointService interface { type TemplateService interface { GetTemplates(ctx context.Context, orgID int64) (map[string]string, error) + SetTemplate(ctx context.Context, orgID int64, tmpl apimodels.MessageTemplate) (apimodels.MessageTemplate, error) + DeleteTemplate(ctx context.Context, orgID int64, name string) error } type NotificationPolicyService interface { @@ -52,7 +54,6 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re } func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimodels.Route) response.Response { - // TODO: lift validation out of definitions.Rotue.UnmarshalJSON and friends into a dedicated validator. err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, alerting_models.ProvenanceAPI) if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") @@ -114,7 +115,7 @@ func (srv *ProvisioningSrv) RouteGetTemplates(c *models.ReqContext) response.Res } func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext) response.Response { - id := web.Params(c.Req)[":ID"] + id := web.Params(c.Req)[":name"] templates, err := srv.templates.GetTemplates(c.Req.Context(), c.OrgId) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") @@ -124,3 +125,29 @@ func (srv *ProvisioningSrv) RouteGetTemplate(c *models.ReqContext) response.Resp } return response.Empty(http.StatusNotFound) } + +func (srv *ProvisioningSrv) RoutePutTemplate(c *models.ReqContext, body apimodels.MessageTemplateContent) response.Response { + name := web.Params(c.Req)[":name"] + tmpl := apimodels.MessageTemplate{ + Name: name, + Template: body.Template, + Provenance: alerting_models.ProvenanceAPI, + } + modified, err := srv.templates.SetTemplate(c.Req.Context(), c.OrgId, tmpl) + if err != nil { + if errors.Is(err, provisioning.ErrValidation) { + return ErrResp(http.StatusBadRequest, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, modified) +} + +func (srv *ProvisioningSrv) RouteDeleteTemplate(c *models.ReqContext) response.Response { + name := web.Params(c.Req)[":name"] + err := srv.templates.DeleteTemplate(c.Req.Context(), c.OrgId, name) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 921ed826ba6..dddf8a431fe 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -184,13 +184,15 @@ func (api *API) authorize(method, path string) web.Handler { case http.MethodGet + "/api/provisioning/policies", http.MethodGet + "/api/provisioning/contact-points", http.MethodGet + "/api/provisioning/templates", - http.MethodGet + "/api/provisioning/templates/{ID}": + http.MethodGet + "/api/provisioning/templates/{name}": return middleware.ReqSignedIn case http.MethodPost + "/api/provisioning/policies", http.MethodPost + "/api/provisioning/contact-points", http.MethodPut + "/api/provisioning/contact-points", - http.MethodDelete + "/api/provisioning/contact-points/{ID}": + http.MethodDelete + "/api/provisioning/contact-points/{ID}", + http.MethodPut + "/api/provisioning/templates/{name}", + http.MethodDelete + "/api/provisioning/templates/{name}": return middleware.ReqEditorRole } diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go index a0623822248..088cfe17e27 100644 --- a/pkg/services/ngalert/api/forked_provisioning.go +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -50,3 +50,11 @@ func (f *ForkedProvisioningApi) forkRouteGetTemplates(ctx *models.ReqContext) re func (f *ForkedProvisioningApi) forkRouteGetTemplate(ctx *models.ReqContext) response.Response { return f.svc.RouteGetTemplate(ctx) } + +func (f *ForkedProvisioningApi) forkRoutePutTemplate(ctx *models.ReqContext, body apimodels.MessageTemplateContent) response.Response { + return f.svc.RoutePutTemplate(ctx, body) +} + +func (f *ForkedProvisioningApi) forkRouteDeleteTemplate(ctx *models.ReqContext) response.Response { + return f.svc.RouteDeleteTemplate(ctx) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 346a60d2f55..81a858f92db 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -21,6 +21,7 @@ import ( type ProvisioningApiForkingService interface { RouteDeleteContactpoints(*models.ReqContext) response.Response + RouteDeleteTemplate(*models.ReqContext) response.Response RouteGetContactpoints(*models.ReqContext) response.Response RouteGetPolicyTree(*models.ReqContext) response.Response RouteGetTemplate(*models.ReqContext) response.Response @@ -28,12 +29,17 @@ type ProvisioningApiForkingService interface { RoutePostContactpoints(*models.ReqContext) response.Response RoutePostPolicyTree(*models.ReqContext) response.Response RoutePutContactpoints(*models.ReqContext) response.Response + RoutePutTemplate(*models.ReqContext) response.Response } func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response { return f.forkRouteDeleteContactpoints(ctx) } +func (f *ForkedProvisioningApi) RouteDeleteTemplate(ctx *models.ReqContext) response.Response { + return f.forkRouteDeleteTemplate(ctx) +} + func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response { return f.forkRouteGetContactpoints(ctx) } @@ -74,6 +80,14 @@ func (f *ForkedProvisioningApi) RoutePutContactpoints(ctx *models.ReqContext) re return f.forkRoutePutContactpoints(ctx, conf) } +func (f *ForkedProvisioningApi) RoutePutTemplate(ctx *models.ReqContext) response.Response { + conf := apimodels.MessageTemplateContent{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePutTemplate(ctx, conf) +} + func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { group.Delete( @@ -86,6 +100,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Delete( + toMacaronPath("/api/provisioning/templates/{name}"), + api.authorize(http.MethodDelete, "/api/provisioning/templates/{name}"), + metrics.Instrument( + http.MethodDelete, + "/api/provisioning/templates/{name}", + srv.RouteDeleteTemplate, + m, + ), + ) group.Get( toMacaronPath("/api/provisioning/contact-points"), api.authorize(http.MethodGet, "/api/provisioning/contact-points"), @@ -107,11 +131,11 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi ), ) group.Get( - toMacaronPath("/api/provisioning/templates/{ID}"), - api.authorize(http.MethodGet, "/api/provisioning/templates/{ID}"), + toMacaronPath("/api/provisioning/templates/{name}"), + api.authorize(http.MethodGet, "/api/provisioning/templates/{name}"), metrics.Instrument( http.MethodGet, - "/api/provisioning/templates/{ID}", + "/api/provisioning/templates/{name}", srv.RouteGetTemplate, m, ), @@ -156,5 +180,15 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Put( + toMacaronPath("/api/provisioning/templates/{name}"), + api.authorize(http.MethodPut, "/api/provisioning/templates/{name}"), + metrics.Instrument( + http.MethodPut, + "/api/provisioning/templates/{name}", + srv.RoutePutTemplate, + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go index 95c3059ae14..65ab1671a27 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go @@ -1,5 +1,13 @@ package definitions +import ( + "fmt" + "regexp" + "strings" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + // swagger:route GET /api/provisioning/templates provisioning RouteGetTemplates // // Get all message templates. @@ -8,7 +16,7 @@ package definitions // 200: []MessageTemplate // 400: ValidationError -// swagger:route GET /api/provisioning/templates/{ID} provisioning RouteGetTemplate +// swagger:route GET /api/provisioning/templates/{name} provisioning RouteGetTemplate // // Get a message template. // @@ -16,9 +24,70 @@ package definitions // 200: MessageTemplate // 404: NotFound +// swagger:route PUT /api/provisioning/templates/{name} provisioning RoutePutTemplate +// +// Updates an existing template. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:route DELETE /api/provisioning/templates/{name} provisioning RouteDeleteTemplate +// +// Delete a template. +// +// Responses: +// 204: Accepted + type MessageTemplate struct { - Name string + Name string + Template string + Provenance models.Provenance `json:"provenance,omitempty"` +} + +type MessageTemplateContent struct { Template string } -type NotFound struct{} +// swagger:parameters RoutePutTemplate +type MessageTemplatePayload struct { + // in:body + Body MessageTemplateContent +} + +func (t *MessageTemplate) ResourceType() string { + return "template" +} + +func (t *MessageTemplate) ResourceID() string { + return t.Name +} + +func (t *MessageTemplate) Validate() error { + if t.Name == "" { + return fmt.Errorf("template must have a name") + } + if t.Template == "" { + return fmt.Errorf("template must have content") + } + + content := strings.TrimSpace(t.Template) + found, err := regexp.MatchString(`\{\{\s*define`, content) + if err != nil { + return fmt.Errorf("failed to match regex: %w", err) + } + if !found { + lines := strings.Split(content, "\n") + for i, s := range lines { + lines[i] = " " + s + } + content = strings.Join(lines, "\n") + content = fmt.Sprintf("{{ define \"%s\" }}\n%s\n{{ end }}", t.Name, content) + } + t.Template = content + + return nil +} diff --git a/pkg/services/ngalert/api/tooling/definitions/shared.go b/pkg/services/ngalert/api/tooling/definitions/shared.go new file mode 100644 index 00000000000..4e90032c4e7 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/shared.go @@ -0,0 +1,3 @@ +package definitions + +type NotFound struct{} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 85dada96ad6..fff01a60d32 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -1330,6 +1330,15 @@ }, "type": "array" }, + "MessageTemplateContent": { + "properties": { + "Template": { + "type": "string" + } + }, + "type": "object", + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "MonthRange": { "properties": { "Begin": { @@ -3516,6 +3525,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "receiver": { + "description": "Receiver receiver", "properties": { "name": { "description": "name", @@ -3526,9 +3536,7 @@ "required": [ "name" ], - "type": "object", - "x-go-name": "Receiver", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "silence": { "description": "Silence silence", @@ -4940,7 +4948,19 @@ ] } }, - "/api/provisioning/templates/{ID}": { + "/api/provisioning/templates/{name}": { + "delete": { + "operationId": "RouteDeleteTemplate", + "responses": { + "204": { + "$ref": "#/responses/Accepted" + } + }, + "summary": "Delete a template.", + "tags": [ + "provisioning" + ] + }, "get": { "operationId": "RouteGetTemplate", "responses": { @@ -4955,6 +4975,36 @@ "tags": [ "provisioning" ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePutTemplate", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/MessageTemplateContent" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Updates an existing template.", + "tags": [ + "provisioning" + ] } }, "/api/ruler/grafana/api/v1/rules": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 8f81b1bb71d..73d0587695b 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1315,7 +1315,7 @@ } } }, - "/api/provisioning/templates/{ID}": { + "/api/provisioning/templates/{name}": { "get": { "tags": [ "provisioning" @@ -1330,6 +1330,48 @@ "$ref": "#/responses/NotFound" } } + }, + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Updates an existing template.", + "operationId": "RoutePutTemplate", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/MessageTemplateContent" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "delete": { + "tags": [ + "provisioning" + ], + "summary": "Delete a template.", + "operationId": "RouteDeleteTemplate", + "responses": { + "204": { + "$ref": "#/responses/Accepted" + } + } } }, "/api/ruler/grafana/api/v1/rules": { @@ -3287,6 +3329,15 @@ }, "$ref": "#/definitions/Matchers" }, + "MessageTemplateContent": { + "type": "object", + "properties": { + "Template": { + "type": "string" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "MonthRange": { "type": "object", "title": "A MonthRange is an inclusive range between [1, 12] where 1 = January.", @@ -5480,6 +5531,7 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "name" @@ -5491,8 +5543,6 @@ "x-go-name": "Name" } }, - "x-go-name": "Receiver", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/receiver" }, "silence": { diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 3e52e481256..d2cce408883 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -14,6 +14,7 @@ type AMConfigStore interface { } // ProvisioningStore is a store of provisioning data for arbitrary objects. +//go:generate mockery --name ProvisioningStore --structname MockProvisioningStore --inpackage --filename provisioning_store_mock.go --with-expecter type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) diff --git a/pkg/services/ngalert/provisioning/provisioning_store_mock.go b/pkg/services/ngalert/provisioning/provisioning_store_mock.go new file mode 100644 index 00000000000..878ae95f25e --- /dev/null +++ b/pkg/services/ngalert/provisioning/provisioning_store_mock.go @@ -0,0 +1,208 @@ +// Code generated by mockery v2.12.0. DO NOT EDIT. + +package provisioning + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/services/ngalert/models" + mock "github.com/stretchr/testify/mock" + + testing "testing" +) + +// MockProvisioningStore is an autogenerated mock type for the ProvisioningStore type +type MockProvisioningStore struct { + mock.Mock +} + +type MockProvisioningStore_Expecter struct { + mock *mock.Mock +} + +func (_m *MockProvisioningStore) EXPECT() *MockProvisioningStore_Expecter { + return &MockProvisioningStore_Expecter{mock: &_m.Mock} +} + +// DeleteProvenance provides a mock function with given fields: ctx, o, org +func (_m *MockProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error { + ret := _m.Called(ctx, o, org) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.Provisionable, int64) error); ok { + r0 = rf(ctx, o, org) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockProvisioningStore_DeleteProvenance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteProvenance' +type MockProvisioningStore_DeleteProvenance_Call struct { + *mock.Call +} + +// DeleteProvenance is a helper method to define mock.On call +// - ctx context.Context +// - o models.Provisionable +// - org int64 +func (_e *MockProvisioningStore_Expecter) DeleteProvenance(ctx interface{}, o interface{}, org interface{}) *MockProvisioningStore_DeleteProvenance_Call { + return &MockProvisioningStore_DeleteProvenance_Call{Call: _e.mock.On("DeleteProvenance", ctx, o, org)} +} + +func (_c *MockProvisioningStore_DeleteProvenance_Call) Run(run func(ctx context.Context, o models.Provisionable, org int64)) *MockProvisioningStore_DeleteProvenance_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.Provisionable), args[2].(int64)) + }) + return _c +} + +func (_c *MockProvisioningStore_DeleteProvenance_Call) Return(_a0 error) *MockProvisioningStore_DeleteProvenance_Call { + _c.Call.Return(_a0) + return _c +} + +// GetProvenance provides a mock function with given fields: ctx, o, org +func (_m *MockProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) { + ret := _m.Called(ctx, o, org) + + var r0 models.Provenance + if rf, ok := ret.Get(0).(func(context.Context, models.Provisionable, int64) models.Provenance); ok { + r0 = rf(ctx, o, org) + } else { + r0 = ret.Get(0).(models.Provenance) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.Provisionable, int64) error); ok { + r1 = rf(ctx, o, org) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockProvisioningStore_GetProvenance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenance' +type MockProvisioningStore_GetProvenance_Call struct { + *mock.Call +} + +// GetProvenance is a helper method to define mock.On call +// - ctx context.Context +// - o models.Provisionable +// - org int64 +func (_e *MockProvisioningStore_Expecter) GetProvenance(ctx interface{}, o interface{}, org interface{}) *MockProvisioningStore_GetProvenance_Call { + return &MockProvisioningStore_GetProvenance_Call{Call: _e.mock.On("GetProvenance", ctx, o, org)} +} + +func (_c *MockProvisioningStore_GetProvenance_Call) Run(run func(ctx context.Context, o models.Provisionable, org int64)) *MockProvisioningStore_GetProvenance_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.Provisionable), args[2].(int64)) + }) + return _c +} + +func (_c *MockProvisioningStore_GetProvenance_Call) Return(_a0 models.Provenance, _a1 error) *MockProvisioningStore_GetProvenance_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// GetProvenances provides a mock function with given fields: ctx, org, resourceType +func (_m *MockProvisioningStore) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) { + ret := _m.Called(ctx, org, resourceType) + + var r0 map[string]models.Provenance + if rf, ok := ret.Get(0).(func(context.Context, int64, string) map[string]models.Provenance); ok { + r0 = rf(ctx, org, resourceType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]models.Provenance) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, org, resourceType) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockProvisioningStore_GetProvenances_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenances' +type MockProvisioningStore_GetProvenances_Call struct { + *mock.Call +} + +// GetProvenances is a helper method to define mock.On call +// - ctx context.Context +// - org int64 +// - resourceType string +func (_e *MockProvisioningStore_Expecter) GetProvenances(ctx interface{}, org interface{}, resourceType interface{}) *MockProvisioningStore_GetProvenances_Call { + return &MockProvisioningStore_GetProvenances_Call{Call: _e.mock.On("GetProvenances", ctx, org, resourceType)} +} + +func (_c *MockProvisioningStore_GetProvenances_Call) Run(run func(ctx context.Context, org int64, resourceType string)) *MockProvisioningStore_GetProvenances_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(string)) + }) + return _c +} + +func (_c *MockProvisioningStore_GetProvenances_Call) Return(_a0 map[string]models.Provenance, _a1 error) *MockProvisioningStore_GetProvenances_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// SetProvenance provides a mock function with given fields: ctx, o, org, p +func (_m *MockProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { + ret := _m.Called(ctx, o, org, p) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.Provisionable, int64, models.Provenance) error); ok { + r0 = rf(ctx, o, org, p) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockProvisioningStore_SetProvenance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProvenance' +type MockProvisioningStore_SetProvenance_Call struct { + *mock.Call +} + +// SetProvenance is a helper method to define mock.On call +// - ctx context.Context +// - o models.Provisionable +// - org int64 +// - p models.Provenance +func (_e *MockProvisioningStore_Expecter) SetProvenance(ctx interface{}, o interface{}, org interface{}, p interface{}) *MockProvisioningStore_SetProvenance_Call { + return &MockProvisioningStore_SetProvenance_Call{Call: _e.mock.On("SetProvenance", ctx, o, org, p)} +} + +func (_c *MockProvisioningStore_SetProvenance_Call) Run(run func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance)) *MockProvisioningStore_SetProvenance_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.Provisionable), args[2].(int64), args[3].(models.Provenance)) + }) + return _c +} + +func (_c *MockProvisioningStore_SetProvenance_Call) Return(_a0 error) *MockProvisioningStore_SetProvenance_Call { + _c.Call.Return(_a0) + return _c +} + +// NewMockProvisioningStore creates a new instance of MockProvisioningStore. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockProvisioningStore(t testing.TB) *MockProvisioningStore { + mock := &MockProvisioningStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/ngalert/provisioning/templates.go b/pkg/services/ngalert/provisioning/templates.go index c60d04c25b6..93be3732a36 100644 --- a/pkg/services/ngalert/provisioning/templates.go +++ b/pkg/services/ngalert/provisioning/templates.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -23,7 +24,107 @@ func NewTemplateService(config AMConfigStore, prov ProvisioningStore, xact Trans log: log, } } + func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) (map[string]string, error) { + revision, err := t.getLastConfiguration(ctx, orgID) + if err != nil { + return nil, err + } + + if revision.cfg.TemplateFiles == nil { + return map[string]string{}, nil + } + + return revision.cfg.TemplateFiles, nil +} + +func (t *TemplateService) SetTemplate(ctx context.Context, orgID int64, tmpl definitions.MessageTemplate) (definitions.MessageTemplate, error) { + err := tmpl.Validate() + if err != nil { + return definitions.MessageTemplate{}, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } + + revision, err := t.getLastConfiguration(ctx, orgID) + if err != nil { + return definitions.MessageTemplate{}, err + } + + if revision.cfg.TemplateFiles == nil { + revision.cfg.TemplateFiles = map[string]string{} + } + revision.cfg.TemplateFiles[tmpl.Name] = tmpl.Template + + serialized, err := SerializeAlertmanagerConfig(*revision.cfg) + if err != nil { + return definitions.MessageTemplate{}, err + } + cmd := models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(serialized), + ConfigurationVersion: revision.version, + FetchedConfigurationHash: revision.concurrencyToken, + Default: false, + OrgID: orgID, + } + err = t.xact.InTransaction(ctx, func(ctx context.Context) error { + err = t.config.UpdateAlertmanagerConfiguration(ctx, &cmd) + if err != nil { + return err + } + err = t.prov.SetProvenance(ctx, &tmpl, orgID, tmpl.Provenance) + if err != nil { + return err + } + return nil + }) + if err != nil { + return definitions.MessageTemplate{}, err + } + + return tmpl, nil +} + +func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, name string) error { + revision, err := t.getLastConfiguration(ctx, orgID) + if err != nil { + return err + } + + delete(revision.cfg.TemplateFiles, name) + + serialized, err := SerializeAlertmanagerConfig(*revision.cfg) + if err != nil { + return err + } + + cmd := models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(serialized), + ConfigurationVersion: revision.version, + FetchedConfigurationHash: revision.concurrencyToken, + Default: false, + OrgID: orgID, + } + err = t.xact.InTransaction(ctx, func(ctx context.Context) error { + err = t.config.UpdateAlertmanagerConfiguration(ctx, &cmd) + if err != nil { + return err + } + tgt := definitions.MessageTemplate{ + Name: name, + } + err = t.prov.DeleteProvenance(ctx, &tgt, orgID) + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + + return nil +} + +func (t *TemplateService) getLastConfiguration(ctx context.Context, orgID int64) (*cfgRevision, error) { q := models.GetLatestAlertmanagerConfigurationQuery{ OrgID: orgID, } @@ -31,18 +132,26 @@ func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) (map[st if err != nil { return nil, err } + if q.Result == nil { return nil, fmt.Errorf("no alertmanager configuration present in this org") } + concurrencyToken := q.Result.ConfigurationHash cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration)) if err != nil { return nil, err } - if cfg.TemplateFiles == nil { - return map[string]string{}, nil - } + return &cfgRevision{ + cfg: cfg, + concurrencyToken: concurrencyToken, + version: q.Result.ConfigurationVersion, + }, nil +} - return cfg.TemplateFiles, nil +type cfgRevision struct { + cfg *definitions.PostableUserConfig + concurrencyToken string + version string } diff --git a/pkg/services/ngalert/provisioning/templates_test.go b/pkg/services/ngalert/provisioning/templates_test.go index cd76e0386d8..2016ec284e3 100644 --- a/pkg/services/ngalert/provisioning/templates_test.go +++ b/pkg/services/ngalert/provisioning/templates_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" mock "github.com/stretchr/testify/mock" @@ -16,7 +17,7 @@ func TestTemplateService(t *testing.T) { t.Run("service returns templates from config file", func(t *testing.T) { sut := createTemplateServiceSut() sut.config.(*MockAMConfigStore).EXPECT(). - setupGetConfig(models.AlertConfiguration{ + getsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: configWithTemplates, }) @@ -29,7 +30,7 @@ func TestTemplateService(t *testing.T) { t.Run("service returns empty map when config file contains no templates", func(t *testing.T) { sut := createTemplateServiceSut() sut.config.(*MockAMConfigStore).EXPECT(). - setupGetConfig(models.AlertConfiguration{ + getsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: defaultConfig, }) @@ -54,7 +55,7 @@ func TestTemplateService(t *testing.T) { t.Run("when config is invalid", func(t *testing.T) { sut := createTemplateServiceSut() sut.config.(*MockAMConfigStore).EXPECT(). - setupGetConfig(models.AlertConfiguration{ + getsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: brokenConfig, }) @@ -76,18 +77,299 @@ func TestTemplateService(t *testing.T) { require.Contains(t, err.Error(), "no alertmanager configuration") }) }) + + t.Run("setting templates", func(t *testing.T) { + t.Run("rejects templates that fail validation", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := definitions.MessageTemplate{ + Name: "", + Template: "", + } + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.ErrorIs(t, err, ErrValidation) + }) + + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(fmt.Errorf("failed")) + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Error(t, err) + }) + + t.Run("when config is invalid", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: brokenConfig, + }) + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to deserialize") + }) + + t.Run("when no AM config in current org", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(nil) + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Error(t, err) + require.Contains(t, err.Error(), "no alertmanager configuration") + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT(). + SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("failed to save provenance")) + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to save provenance") + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT(). + UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(fmt.Errorf("failed to save config")) + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to save config") + }) + }) + + t.Run("adds new template to config file on success", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.NoError(t, err) + }) + + t.Run("succeeds when stitching config file with no templates", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := createMessageTemplate() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: defaultConfig, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + _, err := sut.SetTemplate(context.Background(), 1, tmpl) + + require.NoError(t, err) + }) + + t.Run("normalizes template content with no define", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := definitions.MessageTemplate{ + Name: "name", + Template: "content", + } + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: defaultConfig, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + result, _ := sut.SetTemplate(context.Background(), 1, tmpl) + + exp := "{{ define \"name\" }}\n content\n{{ end }}" + require.Equal(t, exp, result.Template) + }) + + t.Run("avoids normalizing template content with define", func(t *testing.T) { + sut := createTemplateServiceSut() + tmpl := definitions.MessageTemplate{ + Name: "name", + Template: "{{define \"name\"}}content{{end}}", + } + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: defaultConfig, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + result, _ := sut.SetTemplate(context.Background(), 1, tmpl) + + require.Equal(t, tmpl.Template, result.Template) + }) + }) + + t.Run("deleting templates", func(t *testing.T) { + t.Run("propagates errors", func(t *testing.T) { + t.Run("when unable to read config", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(fmt.Errorf("failed")) + + err := sut.DeleteTemplate(context.Background(), 1, "template") + + require.Error(t, err) + }) + + t.Run("when config is invalid", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: brokenConfig, + }) + + err := sut.DeleteTemplate(context.Background(), 1, "template") + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to deserialize") + }) + + t.Run("when no AM config in current org", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(nil) + + err := sut.DeleteTemplate(context.Background(), 1, "template") + + require.Error(t, err) + require.Contains(t, err.Error(), "no alertmanager configuration") + }) + + t.Run("when provenance fails to save", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT(). + DeleteProvenance(mock.Anything, mock.Anything, mock.Anything). + Return(fmt.Errorf("failed to save provenance")) + + err := sut.DeleteTemplate(context.Background(), 1, "template") + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to save provenance") + }) + + t.Run("when AM config fails to save", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT(). + UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(fmt.Errorf("failed to save config")) + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + err := sut.DeleteTemplate(context.Background(), 1, "template") + + require.Error(t, err) + require.Contains(t, err.Error(), "failed to save config") + }) + }) + + t.Run("deletes template from config file on success", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + err := sut.DeleteTemplate(context.Background(), 1, "a") + + require.NoError(t, err) + }) + + t.Run("does not error when deleting templates that do not exist", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: configWithTemplates, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + err := sut.DeleteTemplate(context.Background(), 1, "does not exist") + + require.NoError(t, err) + }) + + t.Run("succeeds when deleting from config file with no template section", func(t *testing.T) { + sut := createTemplateServiceSut() + sut.config.(*MockAMConfigStore).EXPECT(). + getsConfig(models.AlertConfiguration{ + AlertmanagerConfiguration: defaultConfig, + }) + sut.config.(*MockAMConfigStore).EXPECT().saveSucceeds() + sut.prov.(*MockProvisioningStore).EXPECT().saveSucceeds() + + err := sut.DeleteTemplate(context.Background(), 1, "a") + + require.NoError(t, err) + }) + }) } func createTemplateServiceSut() *TemplateService { return &TemplateService{ config: &MockAMConfigStore{}, - prov: NewFakeProvisioningStore(), + prov: &MockProvisioningStore{}, xact: newNopTransactionManager(), log: log.NewNopLogger(), } } -func (m *MockAMConfigStore_Expecter) setupGetConfig(ac models.AlertConfiguration) *MockAMConfigStore_Expecter { +func createMessageTemplate() definitions.MessageTemplate { + return definitions.MessageTemplate{ + Name: "test", + Template: "asdf", + } +} + +func (m *MockAMConfigStore_Expecter) getsConfig(ac models.AlertConfiguration) *MockAMConfigStore_Expecter { m.GetLatestAlertmanagerConfiguration(mock.Anything, mock.Anything). Run(func(ctx context.Context, q *models.GetLatestAlertmanagerConfigurationQuery) { q.Result = &ac @@ -96,6 +378,17 @@ func (m *MockAMConfigStore_Expecter) setupGetConfig(ac models.AlertConfiguration return m } +func (m *MockAMConfigStore_Expecter) saveSucceeds() *MockAMConfigStore_Expecter { + m.UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything).Return(nil) + return m +} + +func (m *MockProvisioningStore_Expecter) saveSucceeds() *MockProvisioningStore_Expecter { + m.SetProvenance(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + m.DeleteProvenance(mock.Anything, mock.Anything, mock.Anything).Return(nil) + return m +} + var defaultConfig = setting.GetAlertmanagerDefaultConfiguration() var configWithTemplates = `