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
This commit is contained in:
Alexander Weaver 2022-05-05 15:21:42 -05:00 committed by GitHub
parent 6de77283c6
commit 0f56462fbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 876 additions and 32 deletions

View File

@ -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)
}

View File

@ -183,13 +183,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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -0,0 +1,3 @@
package definitions
type NotFound struct{}

View File

@ -1364,6 +1364,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": {
@ -3106,6 +3115,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3127,9 +3137,7 @@
"labels",
"receiver"
],
"type": "object",
"x-go-name": "AlertGroup",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "object"
},
"alertGroups": {
"items": {
@ -3559,6 +3567,7 @@
"type": "object"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"name": {
"description": "name",
@ -3569,9 +3578,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",
@ -4971,7 +4978,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": {
@ -4986,6 +5005,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": {

View File

@ -1303,7 +1303,7 @@
}
}
},
"/api/provisioning/templates/{ID}": {
"/api/provisioning/templates/{name}": {
"get": {
"tags": [
"provisioning"
@ -1318,6 +1318,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": {
@ -3303,6 +3345,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.",
@ -5045,6 +5096,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
"description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@ -5067,8 +5119,6 @@
"$ref": "#/definitions/receiver"
}
},
"x-go-name": "AlertGroup",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
@ -5505,6 +5555,7 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"name"
@ -5516,8 +5567,6 @@
"x-go-name": "Name"
}
},
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/receiver"
},
"silence": {

View File

@ -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)

View File

@ -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
}

View File

@ -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 cfg.TemplateFiles, nil
return &cfgRevision{
cfg: cfg,
concurrencyToken: concurrencyToken,
version: q.Result.ConfigurationVersion,
}, nil
}
type cfgRevision struct {
cfg *definitions.PostableUserConfig
concurrencyToken string
version string
}

View File

@ -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,
})
@ -74,18 +75,291 @@ func TestTemplateService(t *testing.T) {
require.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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.ErrorContains(t, err, "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
@ -94,6 +368,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 = `