Alerting: Receiver API (#89707)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() != resourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
user, err := appcontext.User(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
var action accesscontrol.Evaluator
|
||||
switch attr.GetVerb() {
|
||||
case "patch":
|
||||
fallthrough
|
||||
case "create":
|
||||
fallthrough // TODO: Add alert.notifications.receivers:create permission
|
||||
case "update":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:write permission
|
||||
)
|
||||
case "deletecollection":
|
||||
fallthrough
|
||||
case "delete":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:delete permission
|
||||
)
|
||||
}
|
||||
|
||||
eval := accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversRead),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversReadSecrets),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead),
|
||||
)
|
||||
if action != nil {
|
||||
eval = accesscontrol.EvalAll(eval, action)
|
||||
}
|
||||
|
||||
ok, err := ac.Evaluate(ctx, user, eval)
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func getUID(t definitions.GettableApiReceiver) string {
|
||||
sum := fnv.New64()
|
||||
_, _ = sum.Write([]byte(t.Name))
|
||||
return fmt.Sprintf("%016x", sum.Sum64())
|
||||
}
|
||||
|
||||
func convertToK8sResources(orgID int64, receivers []definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) {
|
||||
result := &model.ReceiverList{
|
||||
Items: make([]model.Receiver, 0, len(receivers)),
|
||||
}
|
||||
for _, receiver := range receivers {
|
||||
k8sResource, err := convertToK8sResource(orgID, receiver, namespacer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Items = append(result.Items, *k8sResource)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertToK8sResource(orgID int64, receiver definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.Receiver, error) {
|
||||
spec := model.ReceiverSpec{
|
||||
Title: receiver.Receiver.Name,
|
||||
}
|
||||
provenance := definitions.Provenance(models.ProvenanceNone)
|
||||
for _, integration := range receiver.GrafanaManagedReceivers {
|
||||
if integration.Provenance != receiver.GrafanaManagedReceivers[0].Provenance {
|
||||
return nil, fmt.Errorf("all integrations must have the same provenance")
|
||||
}
|
||||
provenance = integration.Provenance
|
||||
spec.Integrations = append(spec.Integrations, model.Integration{
|
||||
Uid: &integration.UID,
|
||||
Type: integration.Type,
|
||||
DisableResolveMessage: &integration.DisableResolveMessage,
|
||||
Settings: integration.Settings,
|
||||
SecureFields: integration.SecureFields,
|
||||
})
|
||||
}
|
||||
|
||||
uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage
|
||||
return &model.Receiver{
|
||||
TypeMeta: resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(uid), // This is needed to make PATCH work
|
||||
Name: uid, // TODO replace to stable UID when we switch to normal storage
|
||||
Namespace: namespacer(orgID),
|
||||
Annotations: map[string]string{ // TODO find a better place for provenance?
|
||||
"grafana.com/provenance": string(provenance),
|
||||
},
|
||||
ResourceVersion: "", // TODO: Implement optimistic concurrency.
|
||||
},
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) {
|
||||
// TODO: Using GettableApiReceiver instead of PostableApiReceiver so that SecureFields type matches.
|
||||
gettable := definitions.GettableApiReceiver{
|
||||
Receiver: config.Receiver{
|
||||
Name: receiver.Spec.Title,
|
||||
},
|
||||
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, integration := range receiver.Spec.Integrations {
|
||||
grafanaIntegration := definitions.GettableGrafanaReceiver{
|
||||
Name: receiver.Spec.Title,
|
||||
Type: integration.Type,
|
||||
Settings: integration.Settings,
|
||||
SecureFields: integration.SecureFields,
|
||||
//Provenance: "", //TODO: Convert provenance?
|
||||
}
|
||||
if integration.Uid != nil {
|
||||
grafanaIntegration.UID = *integration.Uid
|
||||
}
|
||||
if integration.DisableResolveMessage != nil {
|
||||
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
|
||||
}
|
||||
gettable.GettableGrafanaReceivers.GrafanaManagedReceivers = append(gettable.GettableGrafanaReceivers.GrafanaManagedReceivers, &grafanaIntegration)
|
||||
}
|
||||
|
||||
return gettable, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
notifications "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanaRest.LegacyStorage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
var resourceInfo = notifications.ReceiverResourceInfo
|
||||
|
||||
type ReceiverService interface {
|
||||
GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error)
|
||||
GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error)
|
||||
CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
|
||||
UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
|
||||
DeleteReceiver(ctx context.Context, name string, orgID int64, provenance definitions.Provenance, version string) error
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
service ReceiverService
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
return resourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Destroy() {}
|
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool {
|
||||
return true // namespace == org
|
||||
}
|
||||
|
||||
func (s *legacyStorage) GetSingularName() string {
|
||||
return resourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object {
|
||||
return resourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions) (runtime.Object, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := models.GetReceiversQuery{
|
||||
OrgID: orgId,
|
||||
//Names: ctx.QueryStrings("names"), // TODO: Query params.
|
||||
//Limit: ctx.QueryInt("limit"),
|
||||
//Offset: ctx.QueryInt("offset"),
|
||||
//Decrypt: ctx.QueryBool("decrypt"),
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetReceivers(ctx, q, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResources(orgId, res, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := models.GetReceiverQuery{
|
||||
OrgID: info.OrgID,
|
||||
Name: uid, // TODO: Name/UID mapping or change signature of service.
|
||||
//Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params.
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetReceiver(ctx, q, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResource(info.OrgID, res, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context,
|
||||
obj runtime.Object,
|
||||
createValidation rest.ValidateObjectFunc,
|
||||
_ *metav1.CreateOptions,
|
||||
) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj.DeepCopyObject()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p, ok := obj.(*notifications.Receiver)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user
|
||||
return nil, errors.NewBadRequest("object's metadata.name should be empty")
|
||||
}
|
||||
model, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.service.CreateReceiver(ctx, model, info.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResource(info.OrgID, out, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context,
|
||||
uid string,
|
||||
objInfo rest.UpdatedObjectInfo,
|
||||
createValidation rest.ValidateObjectFunc,
|
||||
updateValidation rest.ValidateObjectUpdateFunc,
|
||||
_ bool,
|
||||
_ *metav1.UpdateOptions,
|
||||
) (runtime.Object, bool, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
obj, err := objInfo.UpdatedObject(ctx, old)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if updateValidation != nil {
|
||||
if err := updateValidation(ctx, obj, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
p, ok := obj.(*notifications.Receiver)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
model, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
if p.ObjectMeta.Name != getUID(model) {
|
||||
return nil, false, errors.NewBadRequest("title cannot be changed. Consider creating a new resource.")
|
||||
}
|
||||
|
||||
updated, err := s.service.UpdateReceiver(ctx, model, info.OrgID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
r, err := convertToK8sResource(info.OrgID, updated, s.namespacer)
|
||||
return r, false, err
|
||||
}
|
||||
|
||||
// GracefulDeleter
|
||||
func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
old, err := s.Get(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if deleteValidation != nil {
|
||||
if err = deleteValidation(ctx, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
version := ""
|
||||
if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil {
|
||||
version = *options.Preconditions.ResourceVersion
|
||||
}
|
||||
p, ok := old.(*notifications.Receiver)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected receiver but got %s", old.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
|
||||
err = s.service.DeleteReceiver(ctx, p.Spec.Title, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
return old, false, err // false - will be deleted async
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, errors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
)
|
||||
|
||||
var _ grafanarest.Storage = (*storage)(nil)
|
||||
|
||||
type storage struct {
|
||||
*genericregistry.Store
|
||||
}
|
||||
|
||||
func (s storage) Compare(storageObj, legacyObj runtime.Object) bool {
|
||||
// TODO implement when supported dual write mode is not Mode0
|
||||
return false
|
||||
}
|
||||
|
||||
func NewStorage(
|
||||
legacySvc ReceiverService,
|
||||
namespacer request.NamespaceMapper,
|
||||
scheme *runtime.Scheme,
|
||||
desiredMode grafanarest.DualWriterMode,
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
reg prometheus.Registerer) (rest.Storage, error) {
|
||||
legacyStore := &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: utils.NewTableConverter(
|
||||
resourceInfo.GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The receiver name"}, // TODO: Add integration types.
|
||||
},
|
||||
func(obj any) ([]interface{}, error) {
|
||||
r, ok := obj.(*model.Receiver)
|
||||
if ok {
|
||||
return []interface{}{
|
||||
r.Name,
|
||||
r.Spec.Title,
|
||||
// r.Spec, //TODO implement formatting for Spec, same as UI?
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("expected resource or info")
|
||||
}),
|
||||
}
|
||||
if optsGetter != nil && desiredMode != grafanarest.Mode0 {
|
||||
strategy := grafanaregistry.NewStrategy(scheme)
|
||||
s := &genericregistry.Store{
|
||||
NewFunc: resourceInfo.NewFunc,
|
||||
NewListFunc: resourceInfo.NewListFunc,
|
||||
PredicateFunc: grafanaregistry.Matcher,
|
||||
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
||||
TableConvertor: legacyStore.tableConverter,
|
||||
CreateStrategy: strategy,
|
||||
UpdateStrategy: strategy,
|
||||
DeleteStrategy: strategy,
|
||||
}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs}
|
||||
if err := s.CompleteWithOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grafanarest.NewDualWriter(desiredMode, legacyStore, storage{Store: s}, reg), nil
|
||||
}
|
||||
return legacyStore, nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
notificationsModels "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apiserver/builder"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
receiver "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/receiver"
|
||||
timeInterval "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/timeinterval"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
@@ -86,8 +87,14 @@ func (t NotificationsAPIBuilder) GetAPIGroupInfo(
|
||||
return nil, fmt.Errorf("failed to initialize time-interval storage: %w", err)
|
||||
}
|
||||
|
||||
recvStorage, err := receiver.NewStorage(nil, t.namespacer, scheme, desiredMode, optsGetter, reg) // TODO: add receiver service
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize receiver storage: %w", err)
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[notificationsModels.VERSION] = map[string]rest.Storage{
|
||||
notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals,
|
||||
notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage,
|
||||
}
|
||||
return &apiGroupInfo, nil
|
||||
}
|
||||
@@ -106,6 +113,8 @@ func (t NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
switch a.GetResource() {
|
||||
case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource:
|
||||
return timeInterval.Authorize(ctx, t.authz, a)
|
||||
case notificationsModels.ReceiverResourceInfo.GroupResource().Resource:
|
||||
return receiver.Authorize(ctx, t.authz, a)
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user