Alerting: Migrate notifications API to app-platform SDK application (#104424)
* introduce alerting notification app * move code as is and remove from old registry * update api server registration * update make file and remove unnecessary args, copy some useful make commands from dashboards * update codeowners * move constants inside module and remove dependency from grafana * add support for selectors to the app builder
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
)
|
||||
|
||||
// AccessControlService provides access control for receivers.
|
||||
type AccessControlService interface {
|
||||
AuthorizeReadSome(ctx context.Context, user identity.Requester) error
|
||||
AuthorizeReadByUID(context.Context, identity.Requester, string) error
|
||||
AuthorizeCreate(context.Context, identity.Requester) error
|
||||
AuthorizeUpdateByUID(context.Context, identity.Requester, string) error
|
||||
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
|
||||
}
|
||||
|
||||
func Authorize(ctx context.Context, ac AccessControlService, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() != ResourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
uid := attr.GetName()
|
||||
|
||||
deny := func(err error) (authorizer.Decision, string, error) {
|
||||
var utilErr errutil.Error
|
||||
if errors.As(err, &utilErr) && utilErr.Reason.Status() == errutil.StatusForbidden {
|
||||
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
|
||||
return authorizer.DecisionDeny, fmt.Sprintf("required permissions: %s", utilErr.PublicPayload["permissions"]), nil
|
||||
}
|
||||
return authorizer.DecisionDeny, utilErr.PublicMessage, nil
|
||||
}
|
||||
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "get":
|
||||
if uid == "" {
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
}
|
||||
if err := ac.AuthorizeReadByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "list":
|
||||
return authorizer.DecisionAllow, "", nil // Always allow listing, receivers are filtered downstream.
|
||||
case "create":
|
||||
if err := ac.AuthorizeCreate(ctx, user); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "patch":
|
||||
fallthrough
|
||||
case "update":
|
||||
if uid == "" {
|
||||
return deny(err)
|
||||
}
|
||||
if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "delete":
|
||||
if uid == "" {
|
||||
return deny(err)
|
||||
}
|
||||
if err := ac.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
default:
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func convertToK8sResources(
|
||||
orgID int64,
|
||||
receivers []*ngmodels.Receiver,
|
||||
accesses map[string]ngmodels.ReceiverPermissionSet,
|
||||
metadatas map[string]ngmodels.ReceiverMetadata,
|
||||
namespacer request.NamespaceMapper,
|
||||
selector fields.Selector,
|
||||
) (*model.ReceiverList, error) {
|
||||
result := &model.ReceiverList{
|
||||
Items: make([]model.Receiver, 0, len(receivers)),
|
||||
}
|
||||
for _, receiver := range receivers {
|
||||
var access *ngmodels.ReceiverPermissionSet
|
||||
if accesses != nil {
|
||||
if a, ok := accesses[receiver.GetUID()]; ok {
|
||||
access = &a
|
||||
}
|
||||
}
|
||||
var metadata *ngmodels.ReceiverMetadata
|
||||
if metadatas != nil {
|
||||
if m, ok := metadatas[receiver.GetUID()]; ok {
|
||||
metadata = &m
|
||||
}
|
||||
}
|
||||
k8sResource, err := convertToK8sResource(orgID, receiver, access, metadata, namespacer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableFields(k8sResource)) {
|
||||
continue
|
||||
}
|
||||
result.Items = append(result.Items, *k8sResource)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertToK8sResource(
|
||||
orgID int64,
|
||||
receiver *ngmodels.Receiver,
|
||||
access *ngmodels.ReceiverPermissionSet,
|
||||
metadata *ngmodels.ReceiverMetadata,
|
||||
namespacer request.NamespaceMapper,
|
||||
) (*model.Receiver, error) {
|
||||
spec := model.Spec{
|
||||
Title: receiver.Name,
|
||||
Integrations: make([]model.Integration, 0, len(receiver.Integrations)),
|
||||
}
|
||||
for _, integration := range receiver.Integrations {
|
||||
spec.Integrations = append(spec.Integrations, model.Integration{
|
||||
Uid: &integration.UID,
|
||||
Type: integration.Config.Type,
|
||||
DisableResolveMessage: &integration.DisableResolveMessage,
|
||||
Settings: maps.Clone(integration.Settings),
|
||||
SecureFields: integration.SecureFields(),
|
||||
})
|
||||
}
|
||||
|
||||
r := &model.Receiver{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(receiver.GetUID()), // This is needed to make PATCH work
|
||||
Name: receiver.GetUID(),
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: receiver.Version,
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
r.SetProvenanceStatus(string(receiver.Provenance))
|
||||
|
||||
if access != nil {
|
||||
for _, action := range ngmodels.ReceiverPermissions() {
|
||||
mappedAction, ok := permissionMapper[action]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown action %v", action)
|
||||
}
|
||||
if can, _ := access.Has(action); can {
|
||||
r.SetAccessControl(mappedAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
rules := make([]string, 0, len(metadata.InUseByRules))
|
||||
for _, rule := range metadata.InUseByRules {
|
||||
rules = append(rules, rule.UID)
|
||||
}
|
||||
r.SetInUse(metadata.InUseByRoutes, rules)
|
||||
}
|
||||
r.UID = gapiutil.CalculateClusterWideUID(r)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
var permissionMapper = map[ngmodels.ReceiverPermission]string{
|
||||
ngmodels.ReceiverPermissionReadSecret: "canReadSecrets",
|
||||
ngmodels.ReceiverPermissionAdmin: "canAdmin",
|
||||
ngmodels.ReceiverPermissionWrite: "canWrite",
|
||||
ngmodels.ReceiverPermissionDelete: "canDelete",
|
||||
}
|
||||
|
||||
func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) {
|
||||
domain := &ngmodels.Receiver{
|
||||
UID: receiver.Name,
|
||||
Name: receiver.Spec.Title,
|
||||
Integrations: make([]*ngmodels.Integration, 0, len(receiver.Spec.Integrations)),
|
||||
Version: receiver.ResourceVersion,
|
||||
Provenance: ngmodels.ProvenanceNone,
|
||||
}
|
||||
|
||||
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
|
||||
for _, integration := range receiver.Spec.Integrations {
|
||||
config, err := ngmodels.IntegrationConfigFromType(integration.Type)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
grafanaIntegration := ngmodels.Integration{
|
||||
Name: receiver.Spec.Title,
|
||||
Config: config,
|
||||
Settings: maps.Clone(integration.Settings),
|
||||
SecureSettings: make(map[string]string),
|
||||
}
|
||||
if integration.Uid != nil {
|
||||
grafanaIntegration.UID = *integration.Uid
|
||||
}
|
||||
if integration.DisableResolveMessage != nil {
|
||||
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
|
||||
}
|
||||
|
||||
domain.Integrations = append(domain.Integrations, &grafanaIntegration)
|
||||
|
||||
if grafanaIntegration.UID != "" {
|
||||
// This is an existing integration, so we track the secure fields being requested to copy over from existing values.
|
||||
secureFields := make([]string, 0, len(integration.SecureFields))
|
||||
for k, isSecure := range integration.SecureFields {
|
||||
if isSecure {
|
||||
secureFields = append(secureFields, k)
|
||||
}
|
||||
}
|
||||
storedSecureFields[grafanaIntegration.UID] = secureFields
|
||||
}
|
||||
}
|
||||
|
||||
return domain, storedSecureFields, nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apierrors "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"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanarest.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type ReceiverService interface {
|
||||
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
|
||||
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
DeleteReceiver(ctx context.Context, name string, provenance definitions.Provenance, version string, orgID int64, user identity.Requester) error
|
||||
}
|
||||
|
||||
type MetadataService interface {
|
||||
AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverPermissionSet, error)
|
||||
InUseMetadata(ctx context.Context, orgID int64, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverMetadata, error)
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
service ReceiverService
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
metadata MetadataService
|
||||
}
|
||||
|
||||
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, opts *internalversion.ListOptions) (runtime.Object, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := ngmodels.GetReceiversQuery{
|
||||
OrgID: orgId,
|
||||
Decrypt: false,
|
||||
//Names: ctx.QueryStrings("names"), // TODO: Query params.
|
||||
//Limit: ctx.QueryInt("limit"),
|
||||
//Offset: ctx.QueryInt("offset"),
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetReceivers(ctx, q, user)
|
||||
if err != nil {
|
||||
// This API should not be returning a forbidden error when the user does not have access to any resources.
|
||||
// This can be true for a contact point creator role, for example.
|
||||
// This should eventually be changed downstream in the auth logic but provisioning API currently relies on this
|
||||
// behaviour to return useful forbidden errors when exporting decrypted receivers.
|
||||
if !errors.Is(err, alertingac.ErrAuthorizationBase) {
|
||||
return nil, err
|
||||
}
|
||||
res = nil
|
||||
}
|
||||
|
||||
accesses, err := s.metadata.AccessControlMetadata(ctx, user, res...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
|
||||
}
|
||||
|
||||
inUses, err := s.metadata.InUseMetadata(ctx, orgId, res...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get in-use metadata: %w", err)
|
||||
}
|
||||
|
||||
return convertToK8sResources(orgId, res, accesses, inUses, s.namespacer, opts.FieldSelector)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
name, err := legacy_storage.UidToName(uid)
|
||||
if err != nil {
|
||||
return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid)
|
||||
}
|
||||
q := ngmodels.GetReceiverQuery{
|
||||
OrgID: info.OrgID,
|
||||
Name: name,
|
||||
Decrypt: false,
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := s.service.GetReceiver(ctx, q, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var access *ngmodels.ReceiverPermissionSet
|
||||
accesses, err := s.metadata.AccessControlMetadata(ctx, user, r)
|
||||
if err == nil {
|
||||
if a, ok := accesses[r.GetUID()]; ok {
|
||||
access = &a
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
|
||||
}
|
||||
|
||||
var inUse *ngmodels.ReceiverMetadata
|
||||
inUses, err := s.metadata.InUseMetadata(ctx, info.OrgID, r)
|
||||
if err == nil {
|
||||
if a, ok := inUses[r.GetUID()]; ok {
|
||||
inUse = &a
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
|
||||
}
|
||||
|
||||
return convertToK8sResource(info.OrgID, r, access, inUse, 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.(*model.Receiver)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
if p.Name != "" { // TODO remove when metadata.name can be defined by user
|
||||
return nil, apierrors.NewBadRequest("object's metadata.name should be empty")
|
||||
}
|
||||
model, _, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := s.service.CreateReceiver(ctx, model, info.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResource(info.OrgID, out, nil, nil, 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
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
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.(*model.Receiver)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
model, storedSecureFields, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
updated, err := s.service.UpdateReceiver(ctx, model, storedSecureFields, info.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
r, err := convertToK8sResource(info.OrgID, updated, nil, nil, 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
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
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
|
||||
}
|
||||
|
||||
err = s.service.DeleteReceiver(ctx, uid, definitions.Provenance(ngmodels.ProvenanceNone), version, info.OrgID, user) // 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, apierrors.NewMethodNotSupported(ResourceInfo.GroupResource(), "deleteCollection")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
func NewStorage(
|
||||
legacySvc ReceiverService,
|
||||
namespacer request.NamespaceMapper,
|
||||
metadata MetadataService,
|
||||
) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: ResourceInfo.TableConverter(),
|
||||
metadata: metadata,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var kind = model.Kind()
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{
|
||||
Definition: []metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The receiver name"}, // TODO: Add integration types.
|
||||
},
|
||||
Reader: 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")
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
notificationsResource "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis"
|
||||
notificationsApp "github.com/grafana/grafana/apps/alerting/notifications/pkg/app"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/receiver"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/routingtree"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/templategroup"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/timeinterval"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder/runner"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type AlertingNotificationsAppProvider struct {
|
||||
app.Provider
|
||||
}
|
||||
|
||||
func RegisterApp(
|
||||
cfg *setting.Cfg,
|
||||
ng *ngalert.AlertNG,
|
||||
) *AlertingNotificationsAppProvider {
|
||||
if ng.IsDisabled() {
|
||||
return nil
|
||||
}
|
||||
appCfg := &runner.AppBuilderConfig{
|
||||
Authorizer: getAuthorizer(ng.Api.AccessControl),
|
||||
LegacyStorageGetter: getLegacyStorage(request.GetNamespaceMapper(cfg), ng),
|
||||
OpenAPIDefGetter: notificationsResource.GetOpenAPIDefinitions,
|
||||
ManagedKinds: notificationsResource.GetKinds(),
|
||||
}
|
||||
|
||||
return &AlertingNotificationsAppProvider{
|
||||
Provider: simple.NewAppProvider(notificationsResource.LocalManifest(), appCfg, notificationsApp.New),
|
||||
}
|
||||
}
|
||||
|
||||
func getAuthorizer(authz accesscontrol.AccessControl) authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetResource() {
|
||||
case templategroup.ResourceInfo.GroupResource().Resource:
|
||||
return templategroup.Authorize(ctx, authz, a)
|
||||
case timeinterval.ResourceInfo.GroupResource().Resource:
|
||||
return timeinterval.Authorize(ctx, authz, a)
|
||||
case receiver.ResourceInfo.GroupResource().Resource:
|
||||
return receiver.Authorize(ctx, ac.NewReceiverAccess[*ngmodels.Receiver](authz, false), a)
|
||||
case routingtree.ResourceInfo.GroupResource().Resource:
|
||||
return routingtree.Authorize(ctx, authz, a)
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
})
|
||||
}
|
||||
|
||||
func getLegacyStorage(namespacer request.NamespaceMapper, ng *ngalert.AlertNG) runner.LegacyStorageGetter {
|
||||
return func(gvr schema.GroupVersionResource) grafanarest.Storage {
|
||||
if gvr == receiver.ResourceInfo.GroupVersionResource() {
|
||||
return receiver.NewStorage(ng.Api.ReceiverService, namespacer, ng.Api.ReceiverService)
|
||||
} else if gvr == timeinterval.ResourceInfo.GroupVersionResource() {
|
||||
return timeinterval.NewStorage(ng.Api.MuteTimings, namespacer)
|
||||
} else if gvr == templategroup.ResourceInfo.GroupVersionResource() {
|
||||
return templategroup.NewStorage(ng.Api.Templates, namespacer)
|
||||
} else if gvr == routingtree.ResourceInfo.GroupVersionResource() {
|
||||
return routingtree.NewStorage(ng.Api.Policies, namespacer)
|
||||
}
|
||||
panic("unknown legacy storage requested: " + gvr.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package routingtree
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"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 := identity.GetRequester(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
|
||||
case "update":
|
||||
fallthrough
|
||||
case "deletecollection":
|
||||
fallthrough
|
||||
case "delete":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRoutesWrite),
|
||||
)
|
||||
}
|
||||
|
||||
eval := accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRoutesRead),
|
||||
)
|
||||
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,229 @@
|
||||
package routingtree
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/pkg/labels"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
promModel "github.com/prometheus/common/model"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/routingtree/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func ConvertToK8sResource(orgID int64, r definitions.Route, version string, namespacer request.NamespaceMapper) (*model.RoutingTree, error) {
|
||||
spec := model.Spec{
|
||||
Defaults: model.RouteDefaults{
|
||||
GroupBy: r.GroupByStr,
|
||||
GroupWait: optionalPrometheusDurationToString(r.GroupWait),
|
||||
GroupInterval: optionalPrometheusDurationToString(r.GroupInterval),
|
||||
RepeatInterval: optionalPrometheusDurationToString(r.RepeatInterval),
|
||||
Receiver: r.Receiver,
|
||||
},
|
||||
}
|
||||
for _, route := range r.Routes {
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
spec.Routes = append(spec.Routes, convertRouteToK8sSubRoute(route))
|
||||
}
|
||||
|
||||
var result = &model.RoutingTree{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: model.UserDefinedRoutingTreeName,
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: version,
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
result.SetProvenanceStatus(string(r.Provenance))
|
||||
result.UID = gapiutil.CalculateClusterWideUID(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertRouteToK8sSubRoute(r *definitions.Route) model.Route {
|
||||
result := model.Route{
|
||||
GroupBy: r.GroupByStr,
|
||||
MuteTimeIntervals: r.MuteTimeIntervals,
|
||||
Continue: r.Continue,
|
||||
GroupWait: optionalPrometheusDurationToString(r.GroupWait),
|
||||
GroupInterval: optionalPrometheusDurationToString(r.GroupInterval),
|
||||
RepeatInterval: optionalPrometheusDurationToString(r.RepeatInterval),
|
||||
Routes: make([]model.Route, 0, len(r.Routes)),
|
||||
}
|
||||
if r.Receiver != "" {
|
||||
result.Receiver = util.Pointer(r.Receiver)
|
||||
}
|
||||
|
||||
if r.Match != nil {
|
||||
keys := slices.Collect(maps.Keys(r.Match))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
result.Matchers = append(result.Matchers, model.Matcher{
|
||||
Label: key,
|
||||
Type: model.MatcherTypeEqual,
|
||||
Value: r.Match[key],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if r.MatchRE != nil {
|
||||
keys := slices.Collect(maps.Keys(r.MatchRE))
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
m := model.Matcher{
|
||||
Label: key,
|
||||
Type: model.MatcherTypeEqualRegex,
|
||||
}
|
||||
value, _ := r.MatchRE[key].MarshalYAML()
|
||||
if s, ok := value.(string); ok {
|
||||
m.Value = s
|
||||
}
|
||||
result.Matchers = append(result.Matchers, m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range r.Matchers {
|
||||
result.Matchers = append(result.Matchers, model.Matcher{
|
||||
Label: m.Name,
|
||||
Type: model.MatcherType(m.Type.String()),
|
||||
Value: m.Value,
|
||||
})
|
||||
}
|
||||
for _, m := range r.ObjectMatchers {
|
||||
result.Matchers = append(result.Matchers, model.Matcher{
|
||||
Label: m.Name,
|
||||
Type: model.MatcherType(m.Type.String()),
|
||||
Value: m.Value,
|
||||
})
|
||||
}
|
||||
for _, route := range r.Routes {
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
result.Routes = append(result.Routes, convertRouteToK8sSubRoute(route))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertToDomainModel(obj *model.RoutingTree) (definitions.Route, string, error) {
|
||||
defaults := obj.Spec.Defaults
|
||||
result := definitions.Route{
|
||||
Receiver: defaults.Receiver,
|
||||
GroupByStr: defaults.GroupBy,
|
||||
Routes: make([]*definitions.Route, 0, len(obj.Spec.Routes)),
|
||||
}
|
||||
path := "."
|
||||
var errs []error
|
||||
|
||||
result.GroupWait = parsePrometheusDuration(defaults.GroupWait, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("obj '%s' has invalid format of 'groupWait': %w", path, err))
|
||||
})
|
||||
result.GroupInterval = parsePrometheusDuration(defaults.GroupInterval, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("obj '%s' has invalid format of 'groupInterval': %w", path, err))
|
||||
})
|
||||
result.RepeatInterval = parsePrometheusDuration(defaults.RepeatInterval, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("obj '%s' has invalid format of 'repeatInterval': %w", path, err))
|
||||
})
|
||||
|
||||
for idx, route := range obj.Spec.Routes {
|
||||
p := fmt.Sprintf("%s[%d]", path, idx)
|
||||
s, err := convertK8sSubRouteToRoute(route, p)
|
||||
if len(err) > 0 {
|
||||
errs = append(errs, err...)
|
||||
} else {
|
||||
result.Routes = append(result.Routes, &s)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return definitions.Route{}, "", errors.Join(errs...)
|
||||
}
|
||||
result.Provenance = ""
|
||||
return result, obj.ResourceVersion, nil
|
||||
}
|
||||
|
||||
func convertK8sSubRouteToRoute(r model.Route, path string) (definitions.Route, []error) {
|
||||
result := definitions.Route{
|
||||
GroupByStr: r.GroupBy,
|
||||
MuteTimeIntervals: r.MuteTimeIntervals,
|
||||
Routes: make([]*definitions.Route, 0, len(r.Routes)),
|
||||
Matchers: make(config.Matchers, 0, len(r.Matchers)),
|
||||
Continue: r.Continue,
|
||||
}
|
||||
if r.Receiver != nil {
|
||||
result.Receiver = *r.Receiver
|
||||
}
|
||||
var errs []error
|
||||
result.GroupWait = parsePrometheusDuration(r.GroupWait, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("route '%s' has invalid format of 'groupWait': %w", path, err))
|
||||
})
|
||||
result.GroupInterval = parsePrometheusDuration(r.GroupInterval, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("route '%s' has invalid format of 'groupInterval': %w", path, err))
|
||||
})
|
||||
result.RepeatInterval = parsePrometheusDuration(r.RepeatInterval, func(err error) {
|
||||
errs = append(errs, fmt.Errorf("route '%s' has invalid format of 'repeatInterval': %w", path, err))
|
||||
})
|
||||
|
||||
for _, matcher := range r.Matchers {
|
||||
var mt labels.MatchType
|
||||
switch matcher.Type {
|
||||
case model.MatcherTypeEqual:
|
||||
mt = labels.MatchEqual
|
||||
case model.MatcherTypeNotEqual:
|
||||
mt = labels.MatchNotEqual
|
||||
case model.MatcherTypeEqualRegex:
|
||||
mt = labels.MatchRegexp
|
||||
case model.MatcherTypeNotEqualRegex:
|
||||
mt = labels.MatchNotRegexp
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("route '%s' has unsupported matcher type: %s", path, matcher.Type))
|
||||
continue
|
||||
}
|
||||
|
||||
m, err := labels.NewMatcher(mt, matcher.Label, matcher.Value)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("route '%s' has illegal matcher: %w", path, err))
|
||||
continue
|
||||
}
|
||||
result.ObjectMatchers = append(result.ObjectMatchers, m)
|
||||
}
|
||||
|
||||
for idx, route := range r.Routes {
|
||||
p := fmt.Sprintf("%s[%d]", path, idx)
|
||||
s, err := convertK8sSubRouteToRoute(route, p)
|
||||
if len(err) > 0 {
|
||||
errs = append(errs, err...)
|
||||
} else {
|
||||
result.Routes = append(result.Routes, &s)
|
||||
}
|
||||
}
|
||||
return result, errs
|
||||
}
|
||||
|
||||
func optionalPrometheusDurationToString(d *promModel.Duration) *string {
|
||||
if d != nil {
|
||||
result := d.String()
|
||||
return &result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePrometheusDuration(s *string, callback func(e error)) *promModel.Duration {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
d, err := promModel.ParseDuration(*s)
|
||||
if err != nil {
|
||||
callback(err)
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package routingtree
|
||||
|
||||
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"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/routingtree/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"
|
||||
alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanarest.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type RouteService interface {
|
||||
GetPolicyTree(ctx context.Context, orgID int64) (definitions.Route, string, error)
|
||||
UpdatePolicyTree(ctx context.Context, orgID int64, tree definitions.Route, p alerting_models.Provenance, version string) (definitions.Route, string, error)
|
||||
ResetPolicyTree(ctx context.Context, orgID int64, p alerting_models.Provenance) (definitions.Route, error)
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
service RouteService
|
||||
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) getUserDefinedRoutingTree(ctx context.Context) (*model.RoutingTree, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, version, err := s.service.GetPolicyTree(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConvertToK8sResource(orgId, res, version, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions) (runtime.Object, error) {
|
||||
user, err := s.getUserDefinedRoutingTree(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RoutingTreeList{
|
||||
Items: []model.RoutingTree{
|
||||
*user,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
if name != model.UserDefinedRoutingTreeName {
|
||||
return nil, errors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
return s.getUserDefinedRoutingTree(ctx)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(_ context.Context,
|
||||
_ runtime.Object,
|
||||
_ rest.ValidateObjectFunc,
|
||||
_ *metav1.CreateOptions,
|
||||
) (runtime.Object, error) {
|
||||
return nil, errors.NewMethodNotSupported(ResourceInfo.GroupResource(), "create")
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, _ rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, _ bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
if name != model.UserDefinedRoutingTreeName {
|
||||
return nil, false, errors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, model.UserDefinedRoutingTreeName, 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.(*model.RoutingTree)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected %s but got %s", ResourceInfo.GroupVersionKind(), obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
|
||||
model, version, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
updated, updatedVersion, err := s.service.UpdatePolicyTree(ctx, info.OrgID, model, alerting_models.ProvenanceNone, version)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
obj, err = ConvertToK8sResource(info.OrgID, updated, updatedVersion, s.namespacer)
|
||||
return obj, false, err
|
||||
}
|
||||
|
||||
// Delete implements rest.GracefulDeleter. It is needed for API server to not crash when it registers DeleteCollection method
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, opts *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
if name != model.UserDefinedRoutingTreeName {
|
||||
return nil, false, errors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
if deleteValidation != nil {
|
||||
if err = deleteValidation(ctx, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
_, err = s.service.ResetPolicyTree(ctx, info.OrgID, alerting_models.ProvenanceNone) // TODO add support for dry-run option
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(_ context.Context, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions, _ *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, errors.NewMethodNotSupported(ResourceInfo.GroupResource(), "delete")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package routingtree
|
||||
|
||||
import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
func NewStorage(legacySvc RouteService, namespacer request.NamespaceMapper) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: rest.NewDefaultTableConvertor(ResourceInfo.GroupResource()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package routingtree
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/routingtree/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var kind = model.Kind()
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package templategroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"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 := identity.GetRequester(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
|
||||
case "update":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesWrite),
|
||||
)
|
||||
case "deletecollection":
|
||||
fallthrough
|
||||
case "delete":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesDelete),
|
||||
)
|
||||
}
|
||||
|
||||
eval := accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesRead),
|
||||
)
|
||||
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,54 @@
|
||||
package templategroup
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/templategroup/v0alpha1"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func convertToK8sResources(orgID int64, list []definitions.NotificationTemplate, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TemplateGroupList, error) {
|
||||
result := &model.TemplateGroupList{}
|
||||
for _, t := range list {
|
||||
item := convertToK8sResource(orgID, t, namespacer)
|
||||
if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableFields(item)) {
|
||||
continue
|
||||
}
|
||||
result.Items = append(result.Items, *item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertToK8sResource(orgID int64, template definitions.NotificationTemplate, namespacer request.NamespaceMapper) *model.TemplateGroup {
|
||||
result := &model.TemplateGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(template.UID),
|
||||
Name: template.UID,
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: template.ResourceVersion,
|
||||
},
|
||||
Spec: model.Spec{
|
||||
Title: template.Name,
|
||||
Content: template.Template,
|
||||
},
|
||||
}
|
||||
result.SetProvenanceStatus(string(template.Provenance))
|
||||
result.UID = gapiutil.CalculateClusterWideUID(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func convertToDomainModel(template *model.TemplateGroup) definitions.NotificationTemplate {
|
||||
return definitions.NotificationTemplate{
|
||||
UID: template.Name,
|
||||
Name: template.Spec.Title,
|
||||
Template: template.Spec.Content,
|
||||
ResourceVersion: template.ResourceVersion,
|
||||
Provenance: definitions.Provenance(ngmodels.ProvenanceNone),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package templategroup
|
||||
|
||||
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/alerting/templates"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/templategroup/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.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type TemplateService interface {
|
||||
GetTemplate(ctx context.Context, orgID int64, nameOrUid string) (definitions.NotificationTemplate, error)
|
||||
GetTemplates(ctx context.Context, orgID int64) ([]definitions.NotificationTemplate, error)
|
||||
CreateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error)
|
||||
UpdateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error)
|
||||
DeleteTemplate(ctx context.Context, orgID int64, nameOrUid string, provenance definitions.Provenance, version string) error
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
service TemplateService
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(context.Context, rest.ValidateObjectFunc, *metav1.DeleteOptions, *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, errors.NewMethodNotSupported(ResourceInfo.GroupResource(), "deleteCollection")
|
||||
}
|
||||
|
||||
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, opts *internalversion.ListOptions) (runtime.Object, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetTemplates(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaultTemplate, err := s.defaultTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResources(orgId, append([]definitions.NotificationTemplate{defaultTemplate}, res...), s.namespacer, opts.FieldSelector)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name == templates.DefaultTemplateName {
|
||||
dto, err := s.defaultTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResource(info.OrgID, dto, s.namespacer), nil
|
||||
}
|
||||
|
||||
dto, err := s.service.GetTemplate(ctx, info.OrgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResource(info.OrgID, dto, s.namespacer), nil
|
||||
}
|
||||
|
||||
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.(*model.TemplateGroup)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
if p.Name != "" { // TODO remove when metadata.name can be defined by user
|
||||
return nil, errors.NewBadRequest("object's metadata.name should be empty")
|
||||
}
|
||||
out, err := s.service.CreateTemplate(ctx, info.OrgID, convertToDomainModel(p))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResource(info.OrgID, out, s.namespacer), nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context,
|
||||
name 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
|
||||
}
|
||||
|
||||
dto, err := s.service.GetTemplate(ctx, info.OrgID, name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
old := convertToK8sResource(info.OrgID, dto, s.namespacer)
|
||||
|
||||
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.(*model.TemplateGroup)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
|
||||
domainModel := convertToDomainModel(p)
|
||||
updated, err := s.service.UpdateTemplate(ctx, info.OrgID, domainModel)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
r := convertToK8sResource(info.OrgID, updated, s.namespacer)
|
||||
return r, false, nil
|
||||
}
|
||||
|
||||
// GracefulDeleter
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name 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, name, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
version := ""
|
||||
if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil {
|
||||
version = *options.Preconditions.ResourceVersion
|
||||
}
|
||||
if deleteValidation != nil {
|
||||
if err = deleteValidation(ctx, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
err = s.service.DeleteTemplate(ctx, info.OrgID, name, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
return old, false, err // false - will be deleted async
|
||||
}
|
||||
|
||||
func (s *legacyStorage) defaultTemplate() (definitions.NotificationTemplate, error) {
|
||||
defaultTemplate, err := templates.DefaultTemplate()
|
||||
if err != nil {
|
||||
return definitions.NotificationTemplate{}, err
|
||||
}
|
||||
|
||||
dto := definitions.NotificationTemplate{
|
||||
Name: model.DefaultTemplateTitle, // User friendly name.
|
||||
UID: defaultTemplate.Name,
|
||||
Provenance: definitions.Provenance("system"),
|
||||
Template: defaultTemplate.Template,
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package templategroup
|
||||
|
||||
import (
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
func NewStorage(
|
||||
legacySvc TemplateService,
|
||||
namespacer request.NamespaceMapper,
|
||||
) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: ResourceInfo.TableConverter(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package templategroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/templategroup/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var kind = model.Kind()
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{
|
||||
Definition: []metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
},
|
||||
Reader: func(obj any) ([]interface{}, error) {
|
||||
r, ok := obj.(*model.TemplateGroup)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected resource or info")
|
||||
}
|
||||
return []interface{}{
|
||||
r.Name,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package timeinterval
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"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 := identity.GetRequester(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
|
||||
case "update":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsWrite),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
|
||||
)
|
||||
case "deletecollection":
|
||||
fallthrough
|
||||
case "delete":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsDelete),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
|
||||
)
|
||||
}
|
||||
|
||||
eval := accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTimeIntervalsRead),
|
||||
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,90 @@
|
||||
package timeinterval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/timeinterval/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
)
|
||||
|
||||
func ConvertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TimeIntervalList, error) {
|
||||
data, err := json.Marshal(intervals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var specs []model.Spec
|
||||
err = json.Unmarshal(data, &specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &model.TimeIntervalList{}
|
||||
|
||||
for idx := range specs {
|
||||
interval := intervals[idx]
|
||||
spec := specs[idx]
|
||||
item := buildTimeInterval(orgID, interval, spec, namespacer)
|
||||
if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableFields(&item)) {
|
||||
continue
|
||||
}
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ConvertToK8sResource(orgID int64, interval definitions.MuteTimeInterval, namespacer request.NamespaceMapper) (*model.TimeInterval, error) {
|
||||
data, err := json.Marshal(interval)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec := model.Spec{}
|
||||
err = json.Unmarshal(data, &spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := buildTimeInterval(orgID, interval, spec, namespacer)
|
||||
result.UID = gapiutil.CalculateClusterWideUID(&result)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func buildTimeInterval(orgID int64, interval definitions.MuteTimeInterval, spec model.Spec, namespacer request.NamespaceMapper) model.TimeInterval {
|
||||
i := model.TimeInterval{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(interval.UID), // TODO This is needed to make PATCH work
|
||||
Name: interval.UID, // TODO replace to stable UID when we switch to normal storage
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: interval.Version,
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
i.SetProvenanceStatus(string(interval.Provenance))
|
||||
i.UID = gapiutil.CalculateClusterWideUID(&i)
|
||||
return i
|
||||
}
|
||||
|
||||
func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInterval, error) {
|
||||
b, err := json.Marshal(interval.Spec)
|
||||
if err != nil {
|
||||
return definitions.MuteTimeInterval{}, err
|
||||
}
|
||||
result := definitions.MuteTimeInterval{}
|
||||
err = json.Unmarshal(b, &result)
|
||||
if err != nil {
|
||||
return definitions.MuteTimeInterval{}, provisioning.MakeErrTimeIntervalInvalid(err)
|
||||
}
|
||||
result.Version = interval.ResourceVersion
|
||||
result.UID = interval.Name
|
||||
result.Provenance = definitions.Provenance(ngmodels.ProvenanceNone)
|
||||
err = result.Validate()
|
||||
if err != nil {
|
||||
return definitions.MuteTimeInterval{}, provisioning.MakeErrTimeIntervalInvalid(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package timeinterval
|
||||
|
||||
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"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/timeinterval/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"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanarest.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type TimeIntervalService interface {
|
||||
GetMuteTimings(ctx context.Context, orgID int64) ([]definitions.MuteTimeInterval, error)
|
||||
CreateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error)
|
||||
UpdateMuteTiming(ctx context.Context, mt definitions.MuteTimeInterval, orgID int64) (definitions.MuteTimeInterval, error)
|
||||
DeleteMuteTiming(ctx context.Context, nameOrUid string, orgID int64, provenance definitions.Provenance, version string) error
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
service TimeIntervalService
|
||||
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, opts *internalversion.ListOptions) (runtime.Object, error) {
|
||||
orgId, err := request.OrgIDForList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetMuteTimings(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ConvertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
timings, err := s.service.GetMuteTimings(ctx, info.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, mt := range timings {
|
||||
if mt.UID == uid {
|
||||
return ConvertToK8sResource(info.OrgID, mt, s.namespacer)
|
||||
}
|
||||
}
|
||||
return nil, errors.NewNotFound(ResourceInfo.GroupResource(), uid)
|
||||
}
|
||||
|
||||
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.(*model.TimeInterval)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected time-interval but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
if p.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.CreateMuteTiming(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.(*model.TimeInterval)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected time-interval but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
interval, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
if p.Name != interval.UID {
|
||||
return nil, false, errors.NewBadRequest("title of cannot be changed. Consider creating a new resource.")
|
||||
}
|
||||
|
||||
updated, err := s.service.UpdateMuteTiming(ctx, interval, 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.(*model.TimeInterval)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected time-interval but got %s", old.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
|
||||
err = s.service.DeleteMuteTiming(ctx, p.Name, info.OrgID, definitions.Provenance(ngmodels.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
return old, false, err // false - will be deleted async
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(context.Context, rest.ValidateObjectFunc, *metav1.DeleteOptions, *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, errors.NewMethodNotSupported(ResourceInfo.GroupResource(), "deleteCollection")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package timeinterval
|
||||
|
||||
import (
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
func NewStorage(
|
||||
legacySvc TimeIntervalService,
|
||||
namespacer request.NamespaceMapper,
|
||||
) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: ResourceInfo.TableConverter(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package timeinterval
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/timeinterval/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var kind = model.Kind()
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{
|
||||
Definition: []metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
},
|
||||
Reader: func(obj any) ([]interface{}, error) {
|
||||
r, ok := obj.(*model.TimeInterval)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected resource or info")
|
||||
}
|
||||
return []interface{}{
|
||||
r.Name,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigations"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
@@ -15,7 +18,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder/runner"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -35,6 +37,7 @@ func ProvideRegistryServiceSink(
|
||||
playlistAppProvider *playlist.PlaylistAppProvider,
|
||||
investigationAppProvider *investigations.InvestigationsAppProvider,
|
||||
advisorAppProvider *advisor.AdvisorAppProvider,
|
||||
alertingNotificationsAppProvider *notifications.AlertingNotificationsAppProvider,
|
||||
grafanaCfg *setting.Cfg,
|
||||
) (*Service, error) {
|
||||
cfgWrapper := func(ctx context.Context) (*rest.Config, error) {
|
||||
@@ -62,6 +65,9 @@ func ProvideRegistryServiceSink(
|
||||
!slices.Contains(grafanaCfg.DisablePlugins, "grafana-advisor-app") {
|
||||
providers = append(providers, advisorAppProvider)
|
||||
}
|
||||
if alertingNotificationsAppProvider != nil {
|
||||
providers = append(providers, alertingNotificationsAppProvider)
|
||||
}
|
||||
apiGroupRunner, err = runner.NewAPIGroupRunner(cfg, providers...)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigations"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
)
|
||||
@@ -15,5 +16,6 @@ var WireSet = wire.NewSet(
|
||||
investigations.RegisterApp,
|
||||
advisor.RegisterApp,
|
||||
checkregistry.ProvideService,
|
||||
notifications.RegisterApp,
|
||||
wire.Bind(new(checkregistry.CheckService), new(*checkregistry.Service)),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user