mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Provisioning API - Notification Policies (#46755)
* Base-line API for provisioning notification policies * Wire API up, some simple tests * Return provenance status through API * Fix missing call * Transactions * Clarity in package dependencies * Unify receivers in definitions * Fix issue introduced by receiver change * Drop unused internal test implementation * FGAC hooks for provisioning routes * Polish, swap names * Asserting on number of exposed routes * Don't bubble up updated object * Integrate with new concurrency token feature in store * Back out duplicated changes * Remove redundant tests * Regenerate and create unit tests for API layer * Integration tests for auth * Address linter errors * Put route behind toggle * Use alternative store API and fix feature toggle in tests * Fixes, polish * Fix whitespace * Re-kick drone * Rename services to provisioning
This commit is contained in:
parent
cb6124c921
commit
dde0b93cf1
@ -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)
|
||||
}
|
||||
}
|
||||
|
51
pkg/services/ngalert/api/api_provisioning.go
Normal file
51
pkg/services/ngalert/api/api_provisioning.go
Normal file
@ -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"})
|
||||
}
|
150
pkg/services/ngalert/api/api_provisioning_test.go
Normal file
150
pkg/services/ngalert/api/api_provisioning_test.go
Normal file
@ -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")
|
||||
}
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
28
pkg/services/ngalert/api/forked_provisioning.go
Normal file
28
pkg/services/ngalert/api/forked_provisioning.go
Normal file
@ -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)
|
||||
}
|
62
pkg/services/ngalert/api/generated_base_api_provisioning.go
Normal file
62
pkg/services/ngalert/api/generated_base_api_provisioning.go
Normal file
@ -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)
|
||||
}
|
@ -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
|
||||
|
26
pkg/services/ngalert/api/tooling/definitions/provisioning.go
Normal file
26
pkg/services/ngalert/api/tooling/definitions/provisioning.go
Normal file
@ -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
|
||||
}
|
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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}}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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,
|
||||
|
138
pkg/services/ngalert/provisioning/notification_policies.go
Normal file
138
pkg/services/ngalert/provisioning/notification_policies.go
Normal file
@ -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
|
||||
}
|
@ -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",
|
||||
}
|
||||
}
|
24
pkg/services/ngalert/provisioning/persist.go
Normal file
24
pkg/services/ngalert/provisioning/persist.go
Normal file
@ -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
|
||||
}
|
20
pkg/services/ngalert/provisioning/serialize.go
Normal file
20
pkg/services/ngalert/provisioning/serialize.go
Normal file
@ -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)
|
||||
}
|
126
pkg/services/ngalert/provisioning/testing.go
Normal file
126
pkg/services/ngalert/provisioning/testing.go
Normal file
@ -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)
|
||||
}
|
@ -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))),
|
||||
|
@ -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",
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
|
157
pkg/tests/api/alerting/api_provisioning_test.go
Normal file
157
pkg/tests/api/alerting/api_provisioning_test.go
Normal file
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user