diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 234b45f75d8..46f62d054db 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -11,11 +11,13 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -64,7 +66,7 @@ type API struct { ExpressionService *expr.Service QuotaService *quota.QuotaService Schedule schedule.ScheduleService - TransactionManager store.TransactionManager + TransactionManager provisioning.TransactionManager RuleStore store.RuleStore InstanceStore store.InstanceStore AlertingStore AlertingStore @@ -74,6 +76,7 @@ type API struct { StateManager *state.Manager SecretsService secrets.Service AccessControl accesscontrol.AccessControl + Policies *provisioning.NotificationPolicyService } // RegisterAPIEndpoints registers API handlers @@ -126,4 +129,11 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { scheduler: api.Schedule, }, ), m) + + if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) { + api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{ + log: logger, + policies: api.Policies, + }), m) + } } diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go new file mode 100644 index 00000000000..679e432107c --- /dev/null +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + domain "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/util" +) + +type ProvisioningSrv struct { + log log.Logger + policies NotificationPolicyService +} + +type NotificationPolicyService interface { + GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) + UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error +} + +func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response { + policies, err := srv.policies.GetPolicyTree(c.Req.Context(), c.OrgId) + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return ErrResp(http.StatusNotFound, err, "") + } + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + + return response.JSON(http.StatusOK, policies) +} + +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, domain.ProvenanceApi) + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return ErrResp(http.StatusNotFound, err, "") + } + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + + return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"}) +} diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go new file mode 100644 index 00000000000..5cac3a26a1b --- /dev/null +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + domain "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +func TestProvisioningApi(t *testing.T) { + t.Run("successful GET policies returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 200, response.Status()) + }) + + t.Run("successful POST policies returns 202", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + tree := apimodels.Route{} + + response := sut.RoutePostPolicyTree(&rc, tree) + + require.Equal(t, 202, response.Status()) + }) + + // TODO: we have not lifted out validation yet. Test that we are returning errors properly once validation has been lifted. + + t.Run("when org has no AM config", func(t *testing.T) { + t.Run("GET policies returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + rc.SignedInUser.OrgId = 2 + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 404, response.Status()) + }) + + t.Run("POST policies returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut() + rc := createTestRequestCtx() + rc.SignedInUser.OrgId = 2 + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 404, response.Status()) + }) + }) + + t.Run("when an unspecified error occurrs", func(t *testing.T) { + t.Run("GET policies returns 500", func(t *testing.T) { + sut := createProvisioningSrvSut() + sut.policies = &fakeFailingNotificationPolicyService{} + rc := createTestRequestCtx() + + response := sut.RouteGetPolicyTree(&rc) + + require.Equal(t, 500, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "something went wrong") + }) + + t.Run("POST policies returns 500", func(t *testing.T) { + sut := createProvisioningSrvSut() + sut.policies = &fakeFailingNotificationPolicyService{} + rc := createTestRequestCtx() + tree := apimodels.Route{} + + response := sut.RoutePostPolicyTree(&rc, tree) + + require.Equal(t, 500, response.Status()) + require.NotEmpty(t, response.Body()) + require.Contains(t, string(response.Body()), "something went wrong") + }) + }) +} + +func createProvisioningSrvSut() ProvisioningSrv { + return ProvisioningSrv{ + log: log.NewNopLogger(), + policies: newFakeNotificationPolicyService(), + } +} + +func createTestRequestCtx() models.ReqContext { + return models.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &models.SignedInUser{ + OrgId: 1, + }, + } +} + +type fakeNotificationPolicyService struct { + tree apimodels.Route + prov domain.Provenance +} + +func newFakeNotificationPolicyService() *fakeNotificationPolicyService { + return &fakeNotificationPolicyService{ + tree: apimodels.Route{ + Receiver: "some-receiver", + }, + prov: domain.ProvenanceNone, + } +} + +func (f *fakeNotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) { + if orgID != 1 { + return provisioning.EmbeddedRoutingTree{}, store.ErrNoAlertmanagerConfiguration + } + return provisioning.EmbeddedRoutingTree{ + Route: f.tree, + Provenance: f.prov, + }, nil +} + +func (f *fakeNotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error { + if orgID != 1 { + return store.ErrNoAlertmanagerConfiguration + } + f.tree = tree + f.prov = p + return nil +} + +type fakeFailingNotificationPolicyService struct{} + +func (f *fakeFailingNotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) { + return provisioning.EmbeddedRoutingTree{}, fmt.Errorf("something went wrong") +} + +func (f *fakeFailingNotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error { + return fmt.Errorf("something went wrong") +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 7094c6aa956..14852ac0f34 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" @@ -28,7 +29,7 @@ import ( ) type RulerSrv struct { - xactManager store.TransactionManager + xactManager provisioning.TransactionManager store store.RuleStore DatasourceCache datasources.CacheService QuotaService *quota.QuotaService diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 9173ad0229c..317c7b21037 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -179,6 +179,13 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodPost + "/api/v1/ngalert/admin_config", http.MethodGet + "/api/v1/ngalert/alertmanagers": return middleware.ReqOrgAdmin + + // Grafana-only Provisioning Read Paths + case http.MethodGet + "/api/provisioning/policies": + return middleware.ReqSignedIn + + case http.MethodPost + "/api/provisioning/policies": + return middleware.ReqEditorRole } if eval != nil { diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 9a1302e7531..b39597a9fb8 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 29) + require.Len(t, paths, 30) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go new file mode 100644 index 00000000000..74213e3c783 --- /dev/null +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -0,0 +1,28 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +// ForkedProvisioningApi always forwards requests to a Grafana backend. +// We do not currently support provisioning of external systems through Grafana's API. +type ForkedProvisioningApi struct { + svc *ProvisioningSrv +} + +// NewForkedProvisioningApi creates a new ForkedProvisioningApi instance. +func NewForkedProvisioningApi(svc *ProvisioningSrv) *ForkedProvisioningApi { + return &ForkedProvisioningApi{ + svc: svc, + } +} + +func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) response.Response { + return f.svc.RouteGetPolicyTree(ctx) +} + +func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response { + return f.svc.RoutePostPolicyTree(ctx, route) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go new file mode 100644 index 00000000000..2d5e72ae571 --- /dev/null +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -0,0 +1,62 @@ +/*Package api contains base API implementation of unified alerting + * + *Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + * + *Do not manually edit these files, please find ngalert/api/swagger-codegen/ for commands on how to generate them. + */ + +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/web" +) + +type ProvisioningApiForkingService interface { + RouteGetPolicyTree(*models.ReqContext) response.Response + RoutePostPolicyTree(*models.ReqContext) response.Response +} + +func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response { + return f.forkRouteGetPolicyTree(ctx) +} + +func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response { + conf := apimodels.Route{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePostPolicyTree(ctx, conf) +} + +func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) { + api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Get( + toMacaronPath("/api/provisioning/policies"), + api.authorize(http.MethodGet, "/api/provisioning/policies"), + metrics.Instrument( + http.MethodGet, + "/api/provisioning/policies", + srv.RouteGetPolicyTree, + m, + ), + ) + group.Post( + toMacaronPath("/api/provisioning/policies"), + api.authorize(http.MethodPost, "/api/provisioning/policies"), + metrics.Instrument( + http.MethodPost, + "/api/provisioning/policies", + srv.RoutePostPolicyTree, + m, + ), + ) + }, middleware.ReqSignedIn) +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index df301280ec2..94eb137d7b7 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -804,6 +804,14 @@ func AsGrafanaRoute(r *config.Route) *Route { return gRoute } +func (r *Route) ResourceType() string { + return "route" +} + +func (r *Route) ResourceID() string { + return "" +} + // Config is the entrypoint for the embedded Alertmanager config with the exception of receivers. // Prometheus historically uses yaml files as the method of configuration and thus some // post-validation is included in the UnmarshalYAML method. Here we simply run this with diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning.go new file mode 100644 index 00000000000..b2019239b36 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning.go @@ -0,0 +1,26 @@ +package definitions + +// swagger:route GET /api/provisioning/policies provisioning RouteGetPolicyTree +// +// Get the notification policy tree. +// +// Responses: +// 200: Route +// 400: ValidationError + +// swagger:route POST /api/provisioning/policies provisioning RoutePostPolicyTree +// +// Sets the notification policy tree. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:parameters RoutePostPolicyTree +type Policytree struct { + // in:body + Body Route +} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 2c852fd2faa..dd87745df0d 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -2803,7 +2803,6 @@ "x-go-package": "github.com/prometheus/alertmanager/timeinterval" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -2836,9 +2835,9 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object", - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -3035,7 +3034,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3057,7 +3055,9 @@ "labels", "receiver" ], - "type": "object" + "type": "object", + "x-go-name": "AlertGroup", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroups": { "items": { @@ -3185,7 +3185,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3244,7 +3243,9 @@ "status", "updatedAt" ], - "type": "object" + "type": "object", + "x-go-name": "GettableAlert", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "gettableAlerts": { "description": "GettableAlerts gettable alerts", @@ -3254,7 +3255,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3306,7 +3306,9 @@ "status", "updatedAt" ], - "type": "object" + "type": "object", + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "gettableSilences": { "items": { @@ -3443,6 +3445,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3482,9 +3485,7 @@ "matchers", "startsAt" ], - "type": "object", - "x-go-name": "PostableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "receiver": { "properties": { @@ -4732,6 +4733,59 @@ ] } }, + "/api/provisioning/policies": { + "get": { + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Get the notification policy tree.", + "tags": [ + "provisioning" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePostPolicyTree", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Sets the notification policy tree.", + "tags": [ + "provisioning" + ] + } + }, "/api/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 481564747b6..fff24ed4f08 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1136,6 +1136,59 @@ } } }, + "/api/provisioning/policies": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get the notification policy tree.", + "operationId": "RouteGetPolicyTree", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Sets the notification policy tree.", + "operationId": "RoutePostPolicyTree", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/Route" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, "/api/ruler/grafana/api/v1/rules": { "get": { "description": "List rule groups", @@ -4564,9 +4617,8 @@ "x-go-package": "github.com/prometheus/alertmanager/timeinterval" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "type": "object", - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "properties": { "ForceQuery": { "type": "boolean" @@ -4599,7 +4651,7 @@ "$ref": "#/definitions/Userinfo" } }, - "x-go-package": "net/url" + "x-go-package": "github.com/prometheus/common/config" }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", @@ -4796,7 +4848,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -4819,6 +4870,8 @@ "$ref": "#/definitions/receiver" } }, + "x-go-name": "AlertGroup", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroup" }, "alertGroups": { @@ -4948,7 +5001,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -5008,6 +5060,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableAlert", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { @@ -5019,7 +5073,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -5072,6 +5125,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { @@ -5210,6 +5265,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -5250,8 +5306,6 @@ "x-go-name": "StartsAt" } }, - "x-go-name": "PostableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/postableSilence" }, "receiver": { diff --git a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache index c8686a22d00..fd4885996b8 100644 --- a/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache +++ b/pkg/services/ngalert/api/tooling/swagger-codegen/templates/controller-api.mustache @@ -24,10 +24,10 @@ func (f *Forked{{classname}}) {{nickname}}(ctx *models.ReqContext) response.Resp if err := web.Bind(ctx.Req, &conf); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - return f.fork{{nickname}}(ctx, conf) + return f.fork{{nickname}}(ctx, conf) {{/bodyParams}} {{^bodyParams}} - return f.fork{{nickname}}(ctx) + return f.fork{{nickname}}(ctx) {{/bodyParams}} } {{/operation}}{{/operations}} diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go index c17e3de09a1..b3ad9612ef5 100644 --- a/pkg/services/ngalert/models/provisioning.go +++ b/pkg/services/ngalert/models/provisioning.go @@ -14,3 +14,9 @@ type Provisionable interface { ResourceID() string ResourceOrgID() int64 } + +// ProvisionableInOrg represents a resource that can be provisioned, given external org-related information. +type ProvisionableInOrg interface { + ResourceType() string + ResourceID() string +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index df2588e1fbb..fe070adf47a 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -136,6 +137,9 @@ func (ng *AlertNG) init() error { ng.stateManager = stateManager ng.schedule = scheduler + // Provisioning + policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log) + api := api.API{ Cfg: ng.Cfg, DatasourceCache: ng.DataSourceCache, @@ -153,6 +157,7 @@ func (ng *AlertNG) init() error { MultiOrgAlertmanager: ng.MultiOrgAlertmanager, StateManager: ng.stateManager, AccessControl: ng.accesscontrol, + Policies: policyService, } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 2c11dc4a9c4..79f35e464bc 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -70,7 +70,7 @@ func (f *FakeConfigStore) SaveAlertmanagerConfigurationWithCallback(_ context.Co return nil } -func (f *FakeConfigStore) UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error { +func (f *FakeConfigStore) UpdateAlertmanagerConfiguration(_ context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { if config, exists := f.configs[cmd.OrgID]; exists && config.ConfigurationHash == cmd.FetchedConfigurationHash { f.configs[cmd.OrgID] = &models.AlertConfiguration{ AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go new file mode 100644 index 00000000000..e77b5f11aea --- /dev/null +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -0,0 +1,138 @@ +package provisioning + +import ( + "context" + "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" +) + +type NotificationPolicyService struct { + amStore AMConfigStore + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger +} + +func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *NotificationPolicyService { + return &NotificationPolicyService{ + amStore: am, + provenanceStore: prov, + xact: xact, + log: log, + } +} + +// TODO: move to Swagger codegen +type EmbeddedRoutingTree struct { + definitions.Route + Provenance models.Provenance +} + +func (nps *NotificationPolicyService) GetAMConfigStore() AMConfigStore { + return nps.amStore +} + +func (nps *NotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (EmbeddedRoutingTree, error) { + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := nps.amStore.GetLatestAlertmanagerConfiguration(ctx, &q) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration)) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + if cfg.AlertmanagerConfig.Config.Route == nil { + return EmbeddedRoutingTree{}, fmt.Errorf("no route present in current alertmanager config") + } + + adapter := provenanceOrgAdapter{ + inner: cfg.AlertmanagerConfig.Route, + orgID: orgID, + } + provenance, err := nps.provenanceStore.GetProvenance(ctx, adapter) + if err != nil { + return EmbeddedRoutingTree{}, err + } + + result := EmbeddedRoutingTree{ + Route: *cfg.AlertmanagerConfig.Route, + Provenance: provenance, + } + + return result, nil +} + +func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgID int64, tree definitions.Route, p models.Provenance) error { + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := nps.amStore.GetLatestAlertmanagerConfiguration(ctx, &q) + if err != nil { + return err + } + + concurrencyToken := q.Result.ConfigurationHash + cfg, err := DeserializeAlertmanagerConfig([]byte(q.Result.AlertmanagerConfiguration)) + if err != nil { + return err + } + + cfg.AlertmanagerConfig.Config.Route = &tree + + serialized, err := SerializeAlertmanagerConfig(*cfg) + if err != nil { + return err + } + cmd := models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(serialized), + ConfigurationVersion: q.Result.ConfigurationVersion, + FetchedConfigurationHash: concurrencyToken, + Default: false, + OrgID: orgID, + } + err = nps.xact.InTransaction(ctx, func(ctx context.Context) error { + err = nps.amStore.UpdateAlertmanagerConfiguration(ctx, &cmd) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &tree, + orgID: orgID, + } + err = nps.provenanceStore.SetProvenance(ctx, adapter, p) + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + + return nil +} + +type provenanceOrgAdapter struct { + inner models.ProvisionableInOrg + orgID int64 +} + +func (a provenanceOrgAdapter) ResourceType() string { + return a.inner.ResourceType() +} + +func (a provenanceOrgAdapter) ResourceID() string { + return a.inner.ResourceID() +} + +func (a provenanceOrgAdapter) ResourceOrgID() int64 { + return a.orgID +} diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go new file mode 100644 index 00000000000..3df52d4bc81 --- /dev/null +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -0,0 +1,88 @@ +package provisioning + +import ( + "context" + "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/stretchr/testify/require" +) + +func TestNotificationPolicyService(t *testing.T) { + t.Run("service gets policy tree from org's AM config", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + + tree, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, "grafana-default-email", tree.Receiver) + }) + + t.Run("service stitches policy tree into org's AM config", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.NoError(t, err) + + updated, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, "a new receiver", updated.Receiver) + }) + + t.Run("default provenance of records is none", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + + tree, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, models.ProvenanceNone, tree.Provenance) + }) + + t.Run("service returns upgraded provenance value", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + require.NoError(t, err) + + updated, err := sut.GetPolicyTree(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, models.ProvenanceApi, updated.Provenance) + }) + + t.Run("service respects concurrency token when updating", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: 1, + } + err := sut.GetAMConfigStore().GetLatestAlertmanagerConfiguration(context.Background(), &q) + require.NoError(t, err) + expectedConcurrencyToken := q.Result.ConfigurationHash + + err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + require.NoError(t, err) + + fake := sut.GetAMConfigStore().(*fakeAMConfigStore) + intercepted := fake.lastSaveCommand + require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) + }) +} + +func createNotificationPolicyServiceSut() *NotificationPolicyService { + return &NotificationPolicyService{ + amStore: newFakeAMConfigStore(), + provenanceStore: newFakeProvisioningStore(), + xact: newNopTransactionManager(), + log: log.NewNopLogger(), + } +} + +func createTestRoutingTree() definitions.Route { + return definitions.Route{ + Receiver: "a new receiver", + } +} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go new file mode 100644 index 00000000000..2a550db327f --- /dev/null +++ b/pkg/services/ngalert/provisioning/persist.go @@ -0,0 +1,24 @@ +package provisioning + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// AMStore is a store of Alertmanager configurations. +type AMConfigStore interface { + GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error + UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error +} + +// ProvisioningStore is a store of provisioning data for arbitrary objects. +type ProvisioningStore interface { + GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) + SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error +} + +// TransactionManager represents the ability to issue and close transactions through contexts. +type TransactionManager interface { + InTransaction(ctx context.Context, work func(ctx context.Context) error) error +} diff --git a/pkg/services/ngalert/provisioning/serialize.go b/pkg/services/ngalert/provisioning/serialize.go new file mode 100644 index 00000000000..db73440bdb8 --- /dev/null +++ b/pkg/services/ngalert/provisioning/serialize.go @@ -0,0 +1,20 @@ +package provisioning + +import ( + "encoding/json" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func DeserializeAlertmanagerConfig(config []byte) (*definitions.PostableUserConfig, error) { + result := definitions.PostableUserConfig{} + if err := json.Unmarshal(config, &result); err != nil { + return nil, fmt.Errorf("failed to deserialize alertmanager configuration: %w", err) + } + return &result, nil +} + +func SerializeAlertmanagerConfig(config definitions.PostableUserConfig) ([]byte, error) { + return json.Marshal(config) +} diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go new file mode 100644 index 00000000000..12c54cc02c7 --- /dev/null +++ b/pkg/services/ngalert/provisioning/testing.go @@ -0,0 +1,126 @@ +package provisioning + +import ( + "context" + "crypto/md5" + "fmt" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +const defaultAlertmanagerConfigJSON = ` +{ + "template_files": null, + "alertmanager_config": { + "route": { + "receiver": "grafana-default-email", + "group_by": [ + "..." + ], + "routes": [{ + "receiver": "grafana-default-email", + "object_matchers": [["a", "=", "b"]] + }] + }, + "templates": null, + "receivers": [{ + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "\u003cexample@email.com\u003e" + }, + "secureFields": {} + }] + }, { + "name": "a new receiver", + "grafana_managed_receiver_configs": [{ + "uid": "", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "\u003canother@email.com\u003e" + }, + "secureFields": {} + }] + }] + } +} +` + +type fakeAMConfigStore struct { + config models.AlertConfiguration + lastSaveCommand *models.SaveAlertmanagerConfigurationCmd +} + +func newFakeAMConfigStore() *fakeAMConfigStore { + return &fakeAMConfigStore{ + config: models.AlertConfiguration{ + AlertmanagerConfiguration: defaultAlertmanagerConfigJSON, + ConfigurationVersion: "v1", + Default: true, + OrgID: 1, + }, + lastSaveCommand: nil, + } +} + +func (f *fakeAMConfigStore) GetLatestAlertmanagerConfiguration(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { + query.Result = &f.config + query.Result.OrgID = query.OrgID + query.Result.ConfigurationHash = fmt.Sprintf("%x", md5.Sum([]byte(f.config.AlertmanagerConfiguration))) + return nil +} + +func (f *fakeAMConfigStore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + f.config = models.AlertConfiguration{ + AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, + ConfigurationVersion: cmd.ConfigurationVersion, + Default: cmd.Default, + OrgID: cmd.OrgID, + } + f.lastSaveCommand = cmd + return nil +} + +type fakeProvisioningStore struct { + records map[int64]map[string]models.Provenance +} + +func newFakeProvisioningStore() *fakeProvisioningStore { + return &fakeProvisioningStore{ + records: map[int64]map[string]models.Provenance{}, + } +} + +func (f *fakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) { + if val, ok := f.records[o.ResourceOrgID()]; ok { + if prov, ok := val[o.ResourceID()]; ok { + return prov, nil + } + } + return models.ProvenanceNone, nil +} + +func (f *fakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error { + orgID := o.ResourceOrgID() + if _, ok := f.records[orgID]; !ok { + f.records[orgID] = map[string]models.Provenance{} + } + f.records[orgID][o.ResourceID()] = p + return nil +} + +type nopTransactionManager struct{} + +func newNopTransactionManager() *nopTransactionManager { + return &nopTransactionManager{} +} + +func (n *nopTransactionManager) InTransaction(ctx context.Context, work func(ctx context.Context) error) error { + return work(ctx) +} diff --git a/pkg/services/ngalert/store/alertmanager.go b/pkg/services/ngalert/store/alertmanager.go index f475d3e1877..0138cc901d5 100644 --- a/pkg/services/ngalert/store/alertmanager.go +++ b/pkg/services/ngalert/store/alertmanager.go @@ -85,8 +85,8 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(ctx context.Context, }) } -func (st *DBstore) UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error { - return st.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { +func (st *DBstore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error { + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { config := models.AlertConfiguration{ AlertmanagerConfiguration: cmd.AlertmanagerConfiguration, ConfigurationHash: fmt.Sprintf("%x", md5.Sum([]byte(cmd.AlertmanagerConfiguration))), diff --git a/pkg/services/ngalert/store/alertmanager_test.go b/pkg/services/ngalert/store/alertmanager_test.go index 89d02ab04e6..ea86b0ff558 100644 --- a/pkg/services/ngalert/store/alertmanager_test.go +++ b/pkg/services/ngalert/store/alertmanager_test.go @@ -49,7 +49,7 @@ func TestAlertManagerHash(t *testing.T) { require.NoError(t, err) require.Equal(t, configMD5, req.Result.ConfigurationHash) newConfig, newConfigMD5 := "my-config-new", fmt.Sprintf("%x", md5.Sum([]byte("my-config-new"))) - err = store.UpdateAlertManagerConfiguration(&models.SaveAlertmanagerConfigurationCmd{ + err = store.UpdateAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{ AlertmanagerConfiguration: newConfig, FetchedConfigurationHash: configMD5, ConfigurationVersion: "v1", @@ -71,7 +71,7 @@ func TestAlertManagerHash(t *testing.T) { err := store.GetLatestAlertmanagerConfiguration(context.Background(), req) require.NoError(t, err) require.Equal(t, configMD5, req.Result.ConfigurationHash) - err = store.UpdateAlertManagerConfiguration(&models.SaveAlertmanagerConfigurationCmd{ + err = store.UpdateAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{ AlertmanagerConfiguration: config, FetchedConfigurationHash: "the-wrong-hash", ConfigurationVersion: "v1", diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 90b61a5e44c..52ea19e7029 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -23,7 +23,7 @@ type AlertingStore interface { GetAllLatestAlertmanagerConfiguration(ctx context.Context) ([]*models.AlertConfiguration, error) SaveAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error SaveAlertmanagerConfigurationWithCallback(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd, callback SaveCallback) error - UpdateAlertManagerConfiguration(cmd *models.SaveAlertmanagerConfigurationCmd) error + UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error } // DBstore stores the alert definitions and instances in the database. diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 6681a290f58..56c5d157606 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -20,13 +20,6 @@ func (pr provenanceRecord) TableName() string { return "provenance_type" } -// ProvisioningStore is a store of provisioning data for arbitrary objects. -type ProvisioningStore interface { - GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) - // TODO: API to query all provenances for a specific type? - SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error -} - // GetProvenance gets the provenance status for a provisionable object. func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) { recordType := o.ResourceType() diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index 4e9770c2d0b..815b799f080 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -2,11 +2,11 @@ package store_test import ( "context" - "fmt" "testing" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/ngalert/tests" "github.com/stretchr/testify/require" @@ -15,7 +15,7 @@ import ( const testAlertingIntervalSeconds = 10 func TestProvisioningStore(t *testing.T) { - store, xact := createSut(tests.SetupTestEnv(t, testAlertingIntervalSeconds)) + store := createProvisioningStoreSut(tests.SetupTestEnv(t, testAlertingIntervalSeconds)) t.Run("Default provenance of a known type is None", func(t *testing.T) { rule := models.AlertRule{ @@ -83,39 +83,8 @@ func TestProvisioningStore(t *testing.T) { require.NoError(t, err) require.Equal(t, models.ProvenanceFile, p) }) - - t.Run("Store saves provenance type when contextual transaction is applied", func(t *testing.T) { - rule := models.AlertRule{ - UID: "456", - } - - err := xact.InTransaction(context.Background(), func(ctx context.Context) error { - return store.SetProvenance(ctx, &rule, models.ProvenanceFile) - }) - require.NoError(t, err) - - provenance, err := store.GetProvenance(context.Background(), &rule) - require.NoError(t, err) - require.Equal(t, models.ProvenanceFile, provenance) - }) - - t.Run("Contextual transaction which errors before saving rolls back type update", func(t *testing.T) { - rule := models.AlertRule{ - UID: "789", - } - - _ = xact.InTransaction(context.Background(), func(ctx context.Context) error { - err := store.SetProvenance(ctx, &rule, models.ProvenanceFile) - require.NoError(t, err) - return fmt.Errorf("something happened!") - }) - - provenance, err := store.GetProvenance(context.Background(), &rule) - require.NoError(t, err) - require.Equal(t, models.ProvenanceNone, provenance) - }) } -func createSut(_ *ngalert.AlertNG, db *store.DBstore) (store.ProvisioningStore, store.TransactionManager) { - return db, db +func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore { + return db } diff --git a/pkg/services/ngalert/store/transactions.go b/pkg/services/ngalert/store/transactions.go index a29223727c1..a9673109082 100644 --- a/pkg/services/ngalert/store/transactions.go +++ b/pkg/services/ngalert/store/transactions.go @@ -2,11 +2,6 @@ package store import "context" -// TransactionManager represents the ability to issue and close transactions through contexts. -type TransactionManager interface { - InTransaction(ctx context.Context, work func(ctx context.Context) error) error -} - func (st *DBstore) InTransaction(ctx context.Context, f func(ctx context.Context) error) error { return st.SQLStore.InTransaction(ctx, f) } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index db669d4121a..35ef0930872 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -39,6 +39,11 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true + cfg.IsFeatureToggleEnabled = func(key string) bool { + // Enable alert provisioning FF when running tests. + return key == featuremgmt.FlagAlertProvisioning + } + m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := sqlstore.InitTestDB(t) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go new file mode 100644 index 00000000000..1cdcef6055b --- /dev/null +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -0,0 +1,157 @@ +package alerting + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/require" +) + +func TestProvisioning(t *testing.T) { + _, err := tracing.InitializeTracerForTest() + require.NoError(t, err) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{featuremgmt.FlagAlertProvisioning}, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + + // Create a users to make authenticated requests + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_VIEWER), + Password: "viewer", + Login: "viewer", + }) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Password: "editor", + Login: "editor", + }) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_ADMIN), + Password: "admin", + Login: "admin", + }) + + t.Run("when provisioning notification policies", func(t *testing.T) { + url := fmt.Sprintf("http://%s/api/provisioning/policies", grafanaListedAddr) + body := ` + { + "receiver": "grafana-default-email", + "group_by": [ + "..." + ], + "routes": [] + }` + + t.Run("un-authenticated GET should 401", func(t *testing.T) { + req := createTestRequest("GET", url, "", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "viewer", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("editor GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "editor", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("admin GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "admin", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("un-authenticated POST should 401", func(t *testing.T) { + req := createTestRequest("POST", url, "", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer POST should 403", func(t *testing.T) { + req := createTestRequest("POST", url, "viewer", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 403, resp.StatusCode) + }) + + t.Run("editor POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "editor", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + + t.Run("admin POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "admin", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + }) +} + +func createTestRequest(method string, url string, user string, body string) *http.Request { + var bodyBuf io.Reader + if body != "" { + bodyBuf = bytes.NewReader([]byte(body)) + } + req, _ := http.NewRequest(method, url, bodyBuf) + if bodyBuf != nil { + req.Header.Set("Content-Type", "application/json") + } + if user != "" { + req.SetBasicAuth(user, user) + } + return req +}