Alerting: Notifications Routes API (#91550)
* Introduce new models RoutingTree, RouteDefaults and Route and api-server to serve them that is backed by provisioning notification policy service. * update method UpdatePolicyTree of notification policy service to return route and new version * declare new actions alert.notifications.routes:read and alert.notifications.routes:write and two corresponding fixed roles. --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com> Co-authored-by: Matthew Jacobson <matthew.jacobson@grafana.com>
This commit is contained in:
co-authored by
Tom Ratcliffe
Matthew Jacobson
parent
fb0221d561
commit
2deced7d40
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
notificationsModels "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
receiver "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/receiver"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/routing_tree"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/template_group"
|
||||
timeInterval "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/timeinterval"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -89,10 +90,16 @@ func (t *NotificationsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiser
|
||||
return fmt.Errorf("failed to initialize templates group storage: %w", err)
|
||||
}
|
||||
|
||||
routeStorage, err := routing_tree.NewStorage(t.ng.Api.Policies, t.namespacer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize route storage: %w", err)
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[notificationsModels.VERSION] = map[string]rest.Storage{
|
||||
notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals,
|
||||
notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage,
|
||||
notificationsModels.TemplateGroupResourceInfo.StoragePath(): templ,
|
||||
notificationsModels.RouteResourceInfo.StoragePath(): routeStorage,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -117,6 +124,7 @@ func (t *NotificationsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3
|
||||
delete(oas.Paths.Paths, root+notificationsModels.ReceiverResourceInfo.GroupResource().Resource)
|
||||
delete(oas.Paths.Paths, root+notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource)
|
||||
delete(oas.Paths.Paths, root+notificationsModels.TemplateGroupResourceInfo.GroupResource().Resource)
|
||||
delete(oas.Paths.Paths, root+notificationsModels.RouteResourceInfo.GroupResource().Resource)
|
||||
|
||||
// The root API discovery list
|
||||
sub := oas.Paths.Paths[root]
|
||||
@@ -136,6 +144,8 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return timeInterval.Authorize(ctx, t.authz, a)
|
||||
case notificationsModels.ReceiverResourceInfo.GroupResource().Resource:
|
||||
return receiver.Authorize(ctx, t.receiverAuth, a)
|
||||
case notificationsModels.RouteResourceInfo.GroupResource().Resource:
|
||||
return routing_tree.Authorize(ctx, t.authz, a)
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package routing_tree
|
||||
|
||||
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,231 @@
|
||||
package routing_tree
|
||||
|
||||
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/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/util"
|
||||
)
|
||||
|
||||
func convertToK8sResource(orgID int64, r definitions.Route, version string, namespacer request.NamespaceMapper) (*model.RoutingTree, error) {
|
||||
spec := model.RoutingTreeSpec{
|
||||
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{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: model.RouteResourceInfo.GroupVersionKind().Kind,
|
||||
APIVersion: model.APIVERSION,
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: model.UserDefinedRoutingTreeName,
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: version,
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
result.SetProvenanceStatus(string(r.Provenance))
|
||||
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,166 @@
|
||||
package routing_tree
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanaRest.LegacyStorage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
var resourceInfo = notifications.RouteResourceInfo
|
||||
|
||||
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) (*notifications.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 ¬ifications.RoutingTreeList{
|
||||
Items: []notifications.RoutingTree{
|
||||
*user,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
if name != notifications.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 != notifications.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, notifications.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.(*notifications.RoutingTree)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected %s but got %s", notifications.ReceiverResourceInfo.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 != notifications.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,17 @@
|
||||
package routing_tree
|
||||
|
||||
import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
)
|
||||
|
||||
func NewStorage(legacySvc RouteService, namespacer request.NamespaceMapper) (rest.Storage, error) {
|
||||
legacyStore := &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()),
|
||||
}
|
||||
// TODO implement dual write for routes. This API is a special beast - the resource is singleton.
|
||||
return legacyStore, nil
|
||||
}
|
||||
Reference in New Issue
Block a user