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:
Yuri Tseretyan
2025-04-30 10:23:56 -04:00
committed by GitHub
parent 454e260207
commit 85344e30c0
50 changed files with 449 additions and 413 deletions
@@ -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{},
)