Alerting: Provisioning API - Contact points (#47197)

This commit is contained in:
Jean-Philippe Quéméner 2022-04-13 22:15:55 +02:00 committed by GitHub
parent 5fb80498b1
commit 388ecb4037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1384 additions and 41 deletions

View File

@ -77,6 +77,7 @@ type API struct {
SecretsService secrets.Service
AccessControl accesscontrol.AccessControl
Policies *provisioning.NotificationPolicyService
ContactPointService *provisioning.ContactPointService
}
// RegisterAPIEndpoints registers API handlers
@ -132,8 +133,9 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) {
api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{
log: logger,
policies: api.Policies,
log: logger,
policies: api.Policies,
contactPointService: api.ContactPointService,
}), m)
}
}

View File

@ -9,20 +9,29 @@ import (
"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"
alerting_models "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"
"github.com/grafana/grafana/pkg/web"
)
type ProvisioningSrv struct {
log log.Logger
policies NotificationPolicyService
log log.Logger
policies NotificationPolicyService
contactPointService ContactPointService
}
type ContactPointService interface {
GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error)
CreateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) (apimodels.EmbeddedContactPoint, error)
UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) error
DeleteContactPoint(ctx context.Context, orgID int64, uid string) error
}
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
UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p alerting_models.Provenance) error
}
func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response {
@ -39,7 +48,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re
func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimodels.Route) response.Response {
// TODO: lift validation out of definitions.Rotue.UnmarshalJSON and friends into a dedicated validator.
err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, domain.ProvenanceApi)
err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, alerting_models.ProvenanceAPI)
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
return ErrResp(http.StatusNotFound, err, "")
}
@ -49,3 +58,37 @@ func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimo
return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"})
}
func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response.Response {
cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), c.OrgId)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusOK, cps)
}
func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
// TODO: provenance is hardcoded for now, change it later to make it more flexible
contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusAccepted, contactPoint)
}
func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint updated"})
}
func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) response.Response {
cpID := web.Params(c.Req)[":ID"]
err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgId, cpID)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"})
}

View File

@ -181,10 +181,14 @@ func (api *API) authorize(method, path string) web.Handler {
return middleware.ReqOrgAdmin
// Grafana-only Provisioning Read Paths
case http.MethodGet + "/api/provisioning/policies":
case http.MethodGet + "/api/provisioning/policies",
http.MethodGet + "/api/provisioning/contact-points":
return middleware.ReqSignedIn
case http.MethodPost + "/api/provisioning/policies":
case http.MethodPost + "/api/provisioning/policies",
http.MethodPost + "/api/provisioning/contact-points",
http.MethodPut + "/api/provisioning/contact-points",
http.MethodDelete + "/api/provisioning/contact-points/{ID}":
return middleware.ReqEditorRole
}

View File

@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
require.Len(t, paths, 30)
require.Len(t, paths, 32)
ac := acmock.New()
api := &API{AccessControl: ac}

View File

@ -26,3 +26,19 @@ func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) r
func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response {
return f.svc.RoutePostPolicyTree(ctx, route)
}
func (f *ForkedProvisioningApi) forkRouteGetContactpoints(ctx *models.ReqContext) response.Response {
return f.svc.RouteGetContactPoints(ctx)
}
func (f *ForkedProvisioningApi) forkRoutePostContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
return f.svc.RoutePostContactPoint(ctx, cp)
}
func (f *ForkedProvisioningApi) forkRoutePutContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response {
return f.svc.RoutePutContactPoint(ctx, cp)
}
func (f *ForkedProvisioningApi) forkRouteDeleteContactpoints(ctx *models.ReqContext) response.Response {
return f.svc.RouteDeleteContactPoint(ctx)
}

View File

@ -20,14 +20,34 @@ import (
)
type ProvisioningApiForkingService interface {
RouteDeleteContactpoints(*models.ReqContext) response.Response
RouteGetContactpoints(*models.ReqContext) response.Response
RouteGetPolicyTree(*models.ReqContext) response.Response
RoutePostContactpoints(*models.ReqContext) response.Response
RoutePostPolicyTree(*models.ReqContext) response.Response
RoutePutContactpoints(*models.ReqContext) response.Response
}
func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response {
return f.forkRouteDeleteContactpoints(ctx)
}
func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response {
return f.forkRouteGetContactpoints(ctx)
}
func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response {
return f.forkRouteGetPolicyTree(ctx)
}
func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) response.Response {
conf := apimodels.EmbeddedContactPoint{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return f.forkRoutePostContactpoints(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response {
conf := apimodels.Route{}
if err := web.Bind(ctx.Req, &conf); err != nil {
@ -36,8 +56,36 @@ func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) resp
return f.forkRoutePostPolicyTree(ctx, conf)
}
func (f *ForkedProvisioningApi) RoutePutContactpoints(ctx *models.ReqContext) response.Response {
conf := apimodels.EmbeddedContactPoint{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return f.forkRoutePutContactpoints(ctx, conf)
}
func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) {
api.RouteRegister.Group("", func(group routing.RouteRegister) {
group.Delete(
toMacaronPath("/api/provisioning/contact-points/{ID}"),
api.authorize(http.MethodDelete, "/api/provisioning/contact-points/{ID}"),
metrics.Instrument(
http.MethodDelete,
"/api/provisioning/contact-points/{ID}",
srv.RouteDeleteContactpoints,
m,
),
)
group.Get(
toMacaronPath("/api/provisioning/contact-points"),
api.authorize(http.MethodGet, "/api/provisioning/contact-points"),
metrics.Instrument(
http.MethodGet,
"/api/provisioning/contact-points",
srv.RouteGetContactpoints,
m,
),
)
group.Get(
toMacaronPath("/api/provisioning/policies"),
api.authorize(http.MethodGet, "/api/provisioning/policies"),
@ -48,6 +96,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Post(
toMacaronPath("/api/provisioning/contact-points"),
api.authorize(http.MethodPost, "/api/provisioning/contact-points"),
metrics.Instrument(
http.MethodPost,
"/api/provisioning/contact-points",
srv.RoutePostContactpoints,
m,
),
)
group.Post(
toMacaronPath("/api/provisioning/policies"),
api.authorize(http.MethodPost, "/api/provisioning/policies"),
@ -58,5 +116,15 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi
m,
),
)
group.Put(
toMacaronPath("/api/provisioning/contact-points"),
api.authorize(http.MethodPut, "/api/provisioning/contact-points"),
metrics.Instrument(
http.MethodPut,
"/api/provisioning/contact-points",
srv.RoutePutContactpoints,
m,
),
)
}, middleware.ReqSignedIn)
}

View File

@ -0,0 +1,157 @@
package definitions
import (
"fmt"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
)
// swagger:route GET /api/provisioning/contact-points provisioning RouteGetContactpoints
//
// Get all the contact points.
//
// Responses:
// 200: Route
// 400: ValidationError
// swagger:route POST /api/provisioning/contact-points provisioning RoutePostContactpoints
//
// Create a contact point.
//
// Consumes:
// - application/json
//
// Responses:
// 202: Accepted
// 400: ValidationError
// swagger:route PUT /api/provisioning/contact-points provisioning RoutePutContactpoints
//
// Update an existing contact point.
//
// Consumes:
// - application/json
//
// Responses:
// 202: Accepted
// 400: ValidationError
// swagger:route DELETE /api/provisioning/contact-points/{ID} provisioning RouteDeleteContactpoints
//
// Delete a contact point.
//
// Consumes:
// - application/json
//
// Responses:
// 202: Accepted
// 400: ValidationError
// swagger:parameters RoutePostContactpoints RoutePutContactpoints
type ContactPointPayload struct {
// in:body
Body EmbeddedContactPoint
}
// EmbeddedContactPoint is the contact point type that is used
// by grafanas embedded alertmanager implementation.
type EmbeddedContactPoint struct {
// UID is the unique identifier of the contact point. This will be
// automatically set be the Grafana.
UID string `json:"uid"`
// Name is used as grouping key in the UI. Contact points with the
// same name will be grouped in the UI.
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Settings *simplejson.Json `json:"settings" binding:"required"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Provenance string `json:"provanance"`
}
const RedactedValue = "[REDACTED]"
func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) error {
if e.Type == "" {
return fmt.Errorf("type should not be an empty string")
}
if e.Settings == nil {
return fmt.Errorf("settings should not be empty")
}
factory, exists := channels.Factory(e.Type)
if !exists {
return fmt.Errorf("unknown type '%s'", e.Type)
}
cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{
Settings: e.Settings,
Type: e.Type,
}, nil, decryptFunc, nil)
if _, err := factory(cfg); err != nil {
return err
}
return nil
}
func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) {
switch e.Type {
case "alertmanager":
return []string{"basicAuthPassword"}, nil
case "dingding":
return []string{}, nil
case "discord":
return []string{}, nil
case "email":
return []string{}, nil
case "googlechat":
return []string{}, nil
case "kafka":
return []string{}, nil
case "line":
return []string{"token"}, nil
case "opsgenie":
return []string{"apiKey"}, nil
case "pagerduty":
return []string{"integrationKey"}, nil
case "pushover":
return []string{"userKey", "apiToken"}, nil
case "sensugo":
return []string{"apiKey"}, nil
case "slack":
return []string{"url", "token"}, nil
case "teams":
return []string{}, nil
case "telegram":
return []string{"bottoken"}, nil
case "threema":
return []string{"api_secret"}, nil
case "victorops":
return []string{}, nil
case "webhook":
return []string{}, nil
case "wecom":
return []string{"url"}, nil
}
return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type)
}
func (e *EmbeddedContactPoint) ExtractSecrets() (map[string]string, error) {
secrets := map[string]string{}
secretKeys, err := e.SecretKeys()
if err != nil {
return nil, err
}
for _, secretKey := range secretKeys {
secretValue := e.Settings.Get(secretKey).MustString()
e.Settings.Del(secretKey)
secrets[secretKey] = secretValue
}
return secrets, nil
}
func (e *EmbeddedContactPoint) ResourceID() string {
return e.UID
}
func (e *EmbeddedContactPoint) ResourceType() string {
return "contactPoint"
}

View File

@ -558,6 +558,36 @@
"type": "object",
"x-go-package": "github.com/prometheus/alertmanager/config"
},
"EmbeddedContactPoint": {
"description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
"properties": {
"disableResolveMessage": {
"type": "boolean",
"x-go-name": "DisableResolveMessage"
},
"name": {
"type": "string",
"x-go-name": "Name"
},
"provanance": {
"type": "string",
"x-go-name": "Provenance"
},
"settings": {
"$ref": "#/definitions/Json"
},
"type": {
"type": "string",
"x-go-name": "Type"
},
"uid": {
"type": "string",
"x-go-name": "UID"
}
},
"type": "object",
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
"ErrorType": {
"title": "ErrorType models the different API error types.",
"type": "string",
@ -3034,6 +3064,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
"description": "AlertGroup alert group",
"properties": {
"alerts": {
"description": "alerts",
@ -3055,17 +3086,14 @@
"labels",
"receiver"
],
"type": "object",
"x-go-name": "AlertGroup",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
"type": "array",
"x-go-name": "AlertGroups",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "array"
},
"alertStatus": {
"description": "AlertStatus alert status",
@ -3255,6 +3283,7 @@
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -3306,17 +3335,14 @@
"status",
"updatedAt"
],
"type": "object",
"x-go-name": "GettableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array",
"x-go-name": "GettableSilences",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "array"
},
"labelSet": {
"additionalProperties": {
@ -3445,7 +3471,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@ -3485,7 +3510,9 @@
"matchers",
"startsAt"
],
"type": "object"
"type": "object",
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"receiver": {
"properties": {
@ -4733,6 +4760,112 @@
]
}
},
"/api/provisioning/contact-points": {
"get": {
"operationId": "RouteGetContactpoints",
"responses": {
"200": {
"description": "Route",
"schema": {
"$ref": "#/definitions/Route"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Get all the contact points.",
"tags": [
"provisioning"
]
},
"post": {
"consumes": [
"application/json"
],
"operationId": "RoutePostContactpoints",
"parameters": [
{
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/EmbeddedContactPoint"
}
}
],
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Create a contact point.",
"tags": [
"provisioning"
]
},
"put": {
"consumes": [
"application/json"
],
"operationId": "RoutePutContactpoints",
"parameters": [
{
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/EmbeddedContactPoint"
}
}
],
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Update an existing contact point.",
"tags": [
"provisioning"
]
}
},
"/api/provisioning/contact-points/{ID}": {
"delete": {
"consumes": [
"application/json"
],
"operationId": "RouteDeleteContactpoints",
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
},
"summary": "Delete a contact point.",
"tags": [
"provisioning"
]
}
},
"/api/provisioning/policies": {
"get": {
"operationId": "RouteGetPolicyTree",

View File

@ -1136,6 +1136,112 @@
}
}
},
"/api/provisioning/contact-points": {
"get": {
"tags": [
"provisioning"
],
"summary": "Get all the contact points.",
"operationId": "RouteGetContactpoints",
"responses": {
"200": {
"description": "Route",
"schema": {
"$ref": "#/definitions/Route"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
},
"put": {
"consumes": [
"application/json"
],
"tags": [
"provisioning"
],
"summary": "Update an existing contact point.",
"operationId": "RoutePutContactpoints",
"parameters": [
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/EmbeddedContactPoint"
}
}
],
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"provisioning"
],
"summary": "Create a contact point.",
"operationId": "RoutePostContactpoints",
"parameters": [
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/EmbeddedContactPoint"
}
}
],
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
}
},
"/api/provisioning/contact-points/{ID}": {
"delete": {
"consumes": [
"application/json"
],
"tags": [
"provisioning"
],
"summary": "Delete a contact point.",
"operationId": "RouteDeleteContactpoints",
"responses": {
"202": {
"$ref": "#/responses/Accepted"
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
}
},
"/api/provisioning/policies": {
"get": {
"tags": [
@ -2369,6 +2475,36 @@
},
"x-go-package": "github.com/prometheus/alertmanager/config"
},
"EmbeddedContactPoint": {
"description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.",
"type": "object",
"properties": {
"disableResolveMessage": {
"type": "boolean",
"x-go-name": "DisableResolveMessage"
},
"name": {
"type": "string",
"x-go-name": "Name"
},
"provanance": {
"type": "string",
"x-go-name": "Provenance"
},
"settings": {
"$ref": "#/definitions/Json"
},
"type": {
"type": "string",
"x-go-name": "Type"
},
"uid": {
"type": "string",
"x-go-name": "UID"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
},
"ErrorType": {
"type": "string",
"title": "ErrorType models the different API error types.",
@ -4848,6 +4984,7 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"alertGroup": {
"description": "AlertGroup alert group",
"type": "object",
"required": [
"alerts",
@ -4870,17 +5007,14 @@
"$ref": "#/definitions/receiver"
}
},
"x-go-name": "AlertGroup",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
},
"x-go-name": "AlertGroups",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/alertGroups"
},
"alertStatus": {
@ -5073,6 +5207,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@ -5125,17 +5260,14 @@
"x-go-name": "UpdatedAt"
}
},
"x-go-name": "GettableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"x-go-name": "GettableSilences",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableSilences"
},
"labelSet": {
@ -5265,7 +5397,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@ -5306,6 +5437,8 @@
"x-go-name": "StartsAt"
}
},
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/postableSilence"
},
"receiver": {

View File

@ -3,8 +3,10 @@ package models
type Provenance string
const (
// ProvenanceNone reflects the provenance when no provenance is stored
// for the requested object in the database.
ProvenanceNone Provenance = ""
ProvenanceApi Provenance = "api"
ProvenanceAPI Provenance = "api"
ProvenanceFile Provenance = "file"
)

View File

@ -139,6 +139,7 @@ func (ng *AlertNG) init() error {
// Provisioning
policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log)
contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log)
api := api.API{
Cfg: ng.Cfg,
@ -158,6 +159,7 @@ func (ng *AlertNG) init() error {
StateManager: ng.stateManager,
AccessControl: ng.accesscontrol,
Policies: policyService,
ContactPointService: contactPointService,
}
api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())

View File

@ -0,0 +1,402 @@
package provisioning
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"github.com/grafana/grafana/pkg/infra/log"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util"
"github.com/prometheus/alertmanager/config"
)
type ContactPointService struct {
amStore AMConfigStore
encryptionService secrets.Service
provenanceStore ProvisioningStore
xact TransactionManager
log log.Logger
}
func NewContactPointService(store store.AlertingStore, encryptionService secrets.Service,
provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger) *ContactPointService {
return &ContactPointService{
amStore: store,
encryptionService: encryptionService,
provenanceStore: provenanceStore,
xact: xact,
log: log,
}
}
func (ecp *ContactPointService) GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) {
cfg, _, err := ecp.getCurrentConfig(ctx, orgID)
if err != nil {
return nil, err
}
provenances, err := ecp.provenanceStore.GetProvenances(ctx, orgID, "contactPoint")
if err != nil {
return nil, err
}
contactPoints := []apimodels.EmbeddedContactPoint{}
for _, contactPoint := range cfg.GetGrafanaReceiverMap() {
embeddedContactPoint := apimodels.EmbeddedContactPoint{
UID: contactPoint.UID,
Type: contactPoint.Type,
Name: contactPoint.Name,
DisableResolveMessage: contactPoint.DisableResolveMessage,
Settings: contactPoint.Settings,
}
if val, exists := provenances[embeddedContactPoint.UID]; exists && val != "" {
embeddedContactPoint.Provenance = string(val)
}
for k, v := range contactPoint.SecureSettings {
decryptedValue, err := ecp.decryptValue(v)
if err != nil {
ecp.log.Warn("decrypting value failed", "err", err.Error())
continue
}
if decryptedValue == "" {
continue
}
embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue)
}
contactPoints = append(contactPoints, embeddedContactPoint)
}
sort.SliceStable(contactPoints, func(i, j int) bool {
return contactPoints[i].Name < contactPoints[j].Name
})
return contactPoints, nil
}
// internal only
func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, orgID int64, uid string) (apimodels.EmbeddedContactPoint, error) {
cfg, _, err := ecp.getCurrentConfig(ctx, orgID)
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
for _, receiver := range cfg.GetGrafanaReceiverMap() {
if receiver.UID != uid {
continue
}
embeddedContactPoint := apimodels.EmbeddedContactPoint{
UID: receiver.UID,
Type: receiver.Type,
Name: receiver.Name,
DisableResolveMessage: receiver.DisableResolveMessage,
Settings: receiver.Settings,
}
for k, v := range receiver.SecureSettings {
decryptedValue, err := ecp.decryptValue(v)
if err != nil {
ecp.log.Warn("decrypting value failed", "err", err.Error())
continue
}
if decryptedValue == "" {
continue
}
embeddedContactPoint.Settings.Set(k, decryptedValue)
}
return embeddedContactPoint, nil
}
return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point with uid '%s' not found", uid)
}
func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID int64,
contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) (apimodels.EmbeddedContactPoint, error) {
if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil {
return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point is not valid: %w", err)
}
cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
extractedSecrets, err := contactPoint.ExtractSecrets()
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
for k, v := range extractedSecrets {
encryptedValue, err := ecp.encryptValue(v)
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
extractedSecrets[k] = encryptedValue
}
contactPoint.UID = util.GenerateShortUID()
grafanaReceiver := &apimodels.PostableGrafanaReceiver{
UID: contactPoint.UID,
Name: contactPoint.Name,
Type: contactPoint.Type,
DisableResolveMessage: contactPoint.DisableResolveMessage,
Settings: contactPoint.Settings,
SecureSettings: extractedSecrets,
}
receiverFound := false
for _, receiver := range cfg.AlertmanagerConfig.Receivers {
if receiver.Name == contactPoint.Name {
receiver.PostableGrafanaReceivers.GrafanaManagedReceivers = append(receiver.PostableGrafanaReceivers.GrafanaManagedReceivers, grafanaReceiver)
receiverFound = true
}
}
if !receiverFound {
cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, &apimodels.PostableApiReceiver{
Receiver: config.Receiver{
Name: grafanaReceiver.Name,
},
PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{
GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{grafanaReceiver},
},
})
}
data, err := json.Marshal(cfg)
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(data),
FetchedConfigurationHash: fetchedHash,
ConfigurationVersion: "v1",
Default: false,
OrgID: orgID,
})
if err != nil {
return err
}
adapter := provenanceOrgAdapter{
inner: &contactPoint,
orgID: orgID,
}
err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance)
if err != nil {
return err
}
contactPoint.Provenance = string(provenance)
return nil
})
if err != nil {
return apimodels.EmbeddedContactPoint{}, err
}
for k := range extractedSecrets {
contactPoint.Settings.Set(k, apimodels.RedactedValue)
}
return contactPoint, nil
}
func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error {
// set all redacted values with the latest known value from the store
rawContactPoint, err := ecp.getContactPointDecrypted(ctx, orgID, contactPoint.UID)
if err != nil {
return err
}
secretKeys, err := contactPoint.SecretKeys()
if err != nil {
return err
}
for _, secretKey := range secretKeys {
secretValue := contactPoint.Settings.Get(secretKey).MustString()
if secretValue == apimodels.RedactedValue {
contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString())
}
}
// validate merged values
if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil {
return err
}
// check that provenance is not changed in a invalid way
storedProvenance, err := ecp.provenanceStore.GetProvenance(ctx, provenanceOrgAdapter{
inner: &contactPoint,
orgID: orgID,
})
if err != nil {
return err
}
if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
return fmt.Errorf("cannot changed provenance from '%s' to '%s'", storedProvenance, provenance)
}
// transform to internal model
extractedSecrets, err := contactPoint.ExtractSecrets()
if err != nil {
return err
}
for k, v := range extractedSecrets {
encryptedValue, err := ecp.encryptValue(v)
if err != nil {
return err
}
extractedSecrets[k] = encryptedValue
}
mergedReceiver := &apimodels.PostableGrafanaReceiver{
UID: contactPoint.UID,
Name: contactPoint.Name,
Type: contactPoint.Type,
DisableResolveMessage: contactPoint.DisableResolveMessage,
Settings: contactPoint.Settings,
SecureSettings: extractedSecrets,
}
// save to store
cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
if err != nil {
return err
}
for _, receiver := range cfg.AlertmanagerConfig.Receivers {
if receiver.Name == contactPoint.Name {
receiverNotFound := true
for i, grafanaReceiver := range receiver.GrafanaManagedReceivers {
if grafanaReceiver.UID == mergedReceiver.UID {
receiverNotFound = false
receiver.GrafanaManagedReceivers[i] = mergedReceiver
break
}
}
if receiverNotFound {
return fmt.Errorf("contact point with uid '%s' not found", mergedReceiver.UID)
}
}
}
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(data),
FetchedConfigurationHash: fetchedHash,
ConfigurationVersion: "v1",
Default: false,
OrgID: orgID,
})
if err != nil {
return err
}
adapter := provenanceOrgAdapter{
inner: &contactPoint,
orgID: orgID,
}
err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance)
if err != nil {
return err
}
contactPoint.Provenance = string(provenance)
return nil
})
}
func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID int64, uid string) error {
cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID)
if err != nil {
return err
}
// Indicates if the full contact point is removed or just one of the
// configurations, as a contactpoint can consist of any number of
// configurations.
fullRemoval := false
// Name of the contact point that will be removed, might be used if a
// full removal is done to check if it's referenced in any route.
name := ""
for i, receiver := range cfg.AlertmanagerConfig.Receivers {
for j, grafanaReceiver := range receiver.GrafanaManagedReceivers {
if grafanaReceiver.UID == uid {
name = grafanaReceiver.Name
receiver.GrafanaManagedReceivers = append(receiver.GrafanaManagedReceivers[:j], receiver.GrafanaManagedReceivers[j+1:]...)
// if this was the last receiver we removed, we remove the whole receiver
if len(receiver.GrafanaManagedReceivers) == 0 {
fullRemoval = true
cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers[:i], cfg.AlertmanagerConfig.Receivers[i+1:]...)
}
break
}
}
}
if fullRemoval && isContactPointInUse(name, []*apimodels.Route{cfg.AlertmanagerConfig.Route}) {
return fmt.Errorf("contact point '%s' is currently used by a notification policy", name)
}
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return ecp.xact.InTransaction(ctx, func(ctx context.Context) error {
err := ecp.provenanceStore.DeleteProvenance(ctx, provenanceOrgAdapter{
inner: &apimodels.EmbeddedContactPoint{
UID: uid,
},
orgID: orgID,
})
if err != nil {
return err
}
return ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(data),
FetchedConfigurationHash: fetchedHash,
ConfigurationVersion: "v1",
Default: false,
OrgID: orgID,
})
})
}
func (ecp *ContactPointService) getCurrentConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, string, error) {
query := &models.GetLatestAlertmanagerConfigurationQuery{
OrgID: orgID,
}
err := ecp.amStore.GetLatestAlertmanagerConfiguration(ctx, query)
if err != nil {
return nil, "", err
}
cfg, err := DeserializeAlertmanagerConfig([]byte(query.Result.AlertmanagerConfiguration))
if err != nil {
return nil, "", err
}
return cfg, query.Result.ConfigurationHash, nil
}
func isContactPointInUse(name string, routes []*apimodels.Route) bool {
if len(routes) == 0 {
return false
}
for _, route := range routes {
if route.Receiver == name {
return true
}
if isContactPointInUse(name, route.Routes) {
return true
}
}
return false
}
func (ecp *ContactPointService) decryptValue(value string) (string, error) {
decodeValue, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decryptedValue, err := ecp.encryptionService.Decrypt(context.Background(), decodeValue)
if err != nil {
return "", err
}
return string(decryptedValue), nil
}
func (ecp *ContactPointService) encryptValue(value string) (string, error) {
encryptedData, err := ecp.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope())
if err != nil {
return "", fmt.Errorf("failed to encrypt secure settings: %w", err)
}
return base64.StdEncoding.EncodeToString(encryptedData), nil
}

View File

@ -0,0 +1,195 @@
package provisioning
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
"github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/stretchr/testify/require"
)
func TestContactPointService(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
t.Run("service gets contact points from AM config", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Len(t, cps, 1)
require.Equal(t, "email receiver", cps[0].Name)
})
t.Run("service stitches contact point into org's AM config", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
_, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Len(t, cps, 2)
require.Equal(t, "test-contact-point", cps[1].Name)
require.Equal(t, "slack", cps[1].Type)
})
t.Run("default provenance of contact points is none", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance))
})
t.Run("it's possible to update provenance from none to API", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance))
err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
cps, err = sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance))
})
t.Run("it's possible to update provenance from none to File", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance))
err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
require.NoError(t, err)
cps, err = sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance))
})
t.Run("it's not possible to update provenance from File to API", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance))
err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.Error(t, err)
})
t.Run("it's not possible to update provenance from API to File", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
cps, err := sut.GetContactPoints(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, newCp.UID, cps[1].UID)
require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance))
err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile)
require.Error(t, err)
})
t.Run("service respects concurrency token when updating", func(t *testing.T) {
sut := createContactPointServiceSut(secretsService)
newCp := createTestContactPoint()
q := models.GetLatestAlertmanagerConfigurationQuery{
OrgID: 1,
}
err := sut.amStore.GetLatestAlertmanagerConfiguration(context.Background(), &q)
require.NoError(t, err)
expectedConcurrencyToken := q.Result.ConfigurationHash
_, err = sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI)
require.NoError(t, err)
fake := sut.amStore.(*fakeAMConfigStore)
intercepted := fake.lastSaveCommand
require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash)
})
}
func TestContactPointInUse(t *testing.T) {
result := isContactPointInUse("test", []*definitions.Route{
{
Receiver: "not-test",
Routes: []*definitions.Route{
{
Receiver: "not-test",
},
{
Receiver: "test",
},
},
},
})
require.True(t, result)
result = isContactPointInUse("test", []*definitions.Route{
{
Receiver: "not-test",
Routes: []*definitions.Route{
{
Receiver: "not-test",
},
{
Receiver: "not-test",
},
},
},
})
require.False(t, result)
}
func createContactPointServiceSut(secretService secrets.Service) *ContactPointService {
return &ContactPointService{
amStore: newFakeAMConfigStore(),
provenanceStore: newFakeProvisioningStore(),
xact: newNopTransactionManager(),
encryptionService: secretService,
log: log.NewNopLogger(),
}
}
func createTestContactPoint() definitions.EmbeddedContactPoint {
settings, _ := simplejson.NewJson([]byte(`{"recipient":"value_recipient","token":"value_token"}`))
return definitions.EmbeddedContactPoint{
Name: "test-contact-point",
Type: "slack",
Settings: settings,
}
}

View File

@ -45,12 +45,12 @@ func TestNotificationPolicyService(t *testing.T) {
sut := createNotificationPolicyServiceSut()
newRoute := createTestRoutingTree()
err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi)
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)
require.Equal(t, models.ProvenanceAPI, updated.Provenance)
})
t.Run("service respects concurrency token when updating", func(t *testing.T) {
@ -63,7 +63,7 @@ func TestNotificationPolicyService(t *testing.T) {
require.NoError(t, err)
expectedConcurrencyToken := q.Result.ConfigurationHash
err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi)
err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI)
require.NoError(t, err)
fake := sut.GetAMConfigStore().(*fakeAMConfigStore)

View File

@ -15,7 +15,9 @@ type AMConfigStore interface {
// ProvisioningStore is a store of provisioning data for arbitrary objects.
type ProvisioningStore interface {
GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error)
GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error)
SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error
DeleteProvenance(ctx context.Context, o models.Provisionable) error
}
// TransactionManager represents the ability to issue and close transactions through contexts.

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@ -99,19 +100,39 @@ func newFakeProvisioningStore() *fakeProvisioningStore {
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 {
if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok {
return prov, nil
}
}
return models.ProvenanceNone, nil
}
func (f *fakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) {
results := make(map[string]models.Provenance)
if val, ok := f.records[orgID]; ok {
for k, v := range val {
if strings.HasSuffix(k, resourceType) {
results[strings.TrimSuffix(k, resourceType)] = v
}
}
}
return results, 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
_ = f.DeleteProvenance(ctx, o) // delete old entries first
f.records[orgID][o.ResourceID()+o.ResourceType()] = p
return nil
}
func (f *fakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable) error {
if val, ok := f.records[o.ResourceOrgID()]; ok {
delete(val, o.ResourceID()+o.ResourceType())
}
return nil
}

View File

@ -45,6 +45,23 @@ func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (mo
return provenance, nil
}
// GetProvenance gets the provenance status for a provisionable object.
func (st DBstore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) {
resultMap := make(map[string]models.Provenance)
err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
filter := "record_type = ? AND org_id = ?"
rawData, err := sess.Table(provenanceRecord{}).Where(filter, resourceType, orgID).Desc("id").Cols("record_key", "provenance").QueryString()
if err != nil {
return fmt.Errorf("failed to query for existing provenance status: %w", err)
}
for _, data := range rawData {
resultMap[data["record_key"]] = models.Provenance(data["provenance"])
}
return nil
})
return resultMap, err
}
// SetProvenance changes the provenance status for a provisionable object.
func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error {
recordType := o.ResourceType()
@ -76,3 +93,15 @@ func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p m
return nil
})
}
// DeleteProvenance deletes the provenance record from the table
func (st DBstore) DeleteProvenance(ctx context.Context, o models.Provisionable) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
_, err := sess.Delete(provenanceRecord{
RecordKey: o.ResourceID(),
RecordType: o.ResourceType(),
OrgID: o.ResourceOrgID(),
})
return err
})
}

View File

@ -73,16 +73,58 @@ func TestProvisioningStore(t *testing.T) {
err = store.SetProvenance(context.Background(), &ruleOrg3, models.ProvenanceFile)
require.NoError(t, err)
err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceApi)
err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI)
require.NoError(t, err)
p, err := store.GetProvenance(context.Background(), &ruleOrg2)
require.NoError(t, err)
require.Equal(t, models.ProvenanceApi, p)
require.Equal(t, models.ProvenanceAPI, p)
p, err = store.GetProvenance(context.Background(), &ruleOrg3)
require.NoError(t, err)
require.Equal(t, models.ProvenanceFile, p)
})
t.Run("Store should return all provenances by type", func(t *testing.T) {
const orgID = 123
ruleOrg1 := models.AlertRule{
UID: "789",
OrgID: orgID,
}
ruleOrg2 := models.AlertRule{
UID: "790",
OrgID: orgID,
}
err := store.SetProvenance(context.Background(), &ruleOrg1, models.ProvenanceFile)
require.NoError(t, err)
err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI)
require.NoError(t, err)
p, err := store.GetProvenances(context.Background(), orgID, ruleOrg1.ResourceType())
require.NoError(t, err)
require.Len(t, p, 2)
require.Equal(t, models.ProvenanceFile, p[ruleOrg1.UID])
require.Equal(t, models.ProvenanceAPI, p[ruleOrg2.UID])
})
t.Run("Store should delete provenance correctly", func(t *testing.T) {
const orgID = 1234
ruleOrg := models.AlertRule{
UID: "7834539",
OrgID: orgID,
}
err := store.SetProvenance(context.Background(), &ruleOrg, models.ProvenanceFile)
require.NoError(t, err)
p, err := store.GetProvenance(context.Background(), &ruleOrg)
require.NoError(t, err)
require.Equal(t, models.ProvenanceFile, p)
err = store.DeleteProvenance(context.Background(), &ruleOrg)
require.NoError(t, err)
p, err = store.GetProvenance(context.Background(), &ruleOrg)
require.NoError(t, err)
require.Equal(t, models.ProvenanceNone, p)
})
}
func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore {

View File

@ -126,6 +126,98 @@ func TestProvisioning(t *testing.T) {
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)
})
})
t.Run("when provisioning contactpoints", func(t *testing.T) {
url := fmt.Sprintf("http://%s/api/provisioning/contact-points", grafanaListedAddr)
body := `
{
"name": "my-contact-point",
"type": "slack",
"settings": {
"recipient": "value_recipient",
"token": "value_token"
}
}`
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)