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:
Alexander Weaver 2022-04-05 16:48:51 -05:00 committed by GitHub
parent cb6124c921
commit dde0b93cf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1055 additions and 78 deletions

View File

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

View 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"})
}

View 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")
}

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

@ -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",

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

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