K8s: Add storage dual writer (#75403)
This commit is contained in:
@@ -177,6 +177,7 @@
|
|||||||
/devenv/docker/blocks/alert_webhook_listener/ @grafana/alerting-backend-product
|
/devenv/docker/blocks/alert_webhook_listener/ @grafana/alerting-backend-product
|
||||||
/devenv/docker/blocks/clickhouse/ @grafana/partner-datasources
|
/devenv/docker/blocks/clickhouse/ @grafana/partner-datasources
|
||||||
/devenv/docker/blocks/collectd/ @grafana/observability-metrics
|
/devenv/docker/blocks/collectd/ @grafana/observability-metrics
|
||||||
|
/devenv/docker/blocks/etcd @grafana/grafana-app-platform-squad
|
||||||
/devenv/docker/blocks/grafana/ @grafana/grafana-as-code
|
/devenv/docker/blocks/grafana/ @grafana/grafana-as-code
|
||||||
/devenv/docker/blocks/graphite/ @grafana/observability-metrics
|
/devenv/docker/blocks/graphite/ @grafana/observability-metrics
|
||||||
/devenv/docker/blocks/graphite09/ @grafana/observability-metrics
|
/devenv/docker/blocks/graphite09/ @grafana/observability-metrics
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
etcd:
|
||||||
|
image: bitnami/etcd:latest
|
||||||
|
restart: always
|
||||||
|
container_name: etcd
|
||||||
|
environment:
|
||||||
|
- ALLOW_NONE_AUTHENTICATION=yes
|
||||||
|
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379
|
||||||
|
ports:
|
||||||
|
- 2379:2379
|
||||||
|
- 2380:2380
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
// +k8s:openapi-gen=true
|
// +k8s:openapi-gen=true
|
||||||
// +groupName=playlist.grafana.io
|
// +groupName=playlist.grafana.io
|
||||||
|
|
||||||
package v1 // import "github.com/grafana/grafana/pkg/apis/playlist/v1"
|
package v0alpha1 // import "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package v0alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
|
||||||
|
grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
|
||||||
|
"github.com/grafana/grafana/pkg/services/playlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ rest.Scoper = (*legacyStorage)(nil)
|
||||||
|
_ rest.SingularNameProvider = (*legacyStorage)(nil)
|
||||||
|
_ rest.Getter = (*legacyStorage)(nil)
|
||||||
|
_ rest.Lister = (*legacyStorage)(nil)
|
||||||
|
_ rest.Storage = (*legacyStorage)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
type legacyStorage struct {
|
||||||
|
service playlist.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLegacyStorage(s playlist.Service) *legacyStorage {
|
||||||
|
return &legacyStorage{
|
||||||
|
service: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) New() runtime.Object {
|
||||||
|
return &Playlist{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) Destroy() {}
|
||||||
|
|
||||||
|
func (s *legacyStorage) NamespaceScoped() bool {
|
||||||
|
return true // namespace == org
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) GetSingularName() string {
|
||||||
|
return "playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) NewList() runtime.Object {
|
||||||
|
return &PlaylistList{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||||
|
return rest.NewDefaultTableConvertor(Resource("playlists")).ConvertToTable(ctx, object, tableOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||||
|
// TODO: handle fetching all available orgs when no namespace is specified
|
||||||
|
// To test: kubectl get playlists --all-namespaces
|
||||||
|
orgId, ok := grafanarequest.OrgIDFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
orgId = 1 // TODO: default org ID 1 for now
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
if options.Limit > 0 {
|
||||||
|
limit = int(options.Limit)
|
||||||
|
}
|
||||||
|
res, err := s.service.Search(ctx, &playlist.GetPlaylistsQuery{
|
||||||
|
OrgId: orgId,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := &PlaylistList{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "PlaylistList",
|
||||||
|
APIVersion: APIVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, v := range res {
|
||||||
|
p := Playlist{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "Playlist",
|
||||||
|
APIVersion: APIVersion,
|
||||||
|
},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: v.UID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p.Name = v.Name + " // " + v.Interval
|
||||||
|
list.Items = append(list.Items, p)
|
||||||
|
}
|
||||||
|
if len(list.Items) == limit {
|
||||||
|
list.Continue = "<more>" // TODO?
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||||
|
orgId, ok := grafanarequest.OrgIDFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
orgId = 1 // TODO: default org ID 1 for now
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := s.service.Get(ctx, &playlist.GetPlaylistByUidQuery{
|
||||||
|
UID: name,
|
||||||
|
OrgId: orgId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
return nil, fmt.Errorf("not found?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Playlist{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "Playlist",
|
||||||
|
APIVersion: APIVersion,
|
||||||
|
},
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: p.Uid,
|
||||||
|
},
|
||||||
|
Name: p.Name + "//" + p.Interval,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v0alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
common "k8s.io/kube-openapi/pkg/common"
|
common "k8s.io/kube-openapi/pkg/common"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NOTE: this must match the golang fully qualifid name!
|
// NOTE: this must match the golang fully qualifid name!
|
||||||
const kindKey = "github.com/grafana/grafana/pkg/apis/playlist/v1.Playlist"
|
const kindKey = "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1.Playlist"
|
||||||
|
|
||||||
func getOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
func getOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||||
return map[string]common.OpenAPIDefinition{
|
return map[string]common.OpenAPIDefinition{
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
package v1
|
package v0alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||||
|
"k8s.io/apiserver/pkg/registry/generic"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||||
common "k8s.io/kube-openapi/pkg/common"
|
common "k8s.io/kube-openapi/pkg/common"
|
||||||
"k8s.io/kube-openapi/pkg/spec3"
|
"k8s.io/kube-openapi/pkg/spec3"
|
||||||
|
|
||||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
|
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
|
||||||
|
grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/services/playlist"
|
"github.com/grafana/grafana/pkg/services/playlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GroupName is the group name for this API.
|
// GroupName is the group name for this API.
|
||||||
const GroupName = "playlist.x.grafana.com"
|
const GroupName = "playlist.x.grafana.com"
|
||||||
const VersionID = "v0-alpha" //
|
const VersionID = "v0alpha1" //
|
||||||
const APIVersion = GroupName + "/" + VersionID
|
const APIVersion = GroupName + "/" + VersionID
|
||||||
|
|
||||||
var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
|
var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
|
||||||
@@ -45,15 +47,25 @@ func (b *PlaylistAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
|||||||
func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
||||||
scheme *runtime.Scheme,
|
scheme *runtime.Scheme,
|
||||||
codecs serializer.CodecFactory, // pointer?
|
codecs serializer.CodecFactory, // pointer?
|
||||||
) *genericapiserver.APIGroupInfo {
|
optsGetter generic.RESTOptionsGetter,
|
||||||
|
) (*genericapiserver.APIGroupInfo, error) {
|
||||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs)
|
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs)
|
||||||
storage := map[string]rest.Storage{}
|
storage := map[string]rest.Storage{}
|
||||||
storage["playlists"] = &handler{
|
|
||||||
service: b.service,
|
legacyStore := newLegacyStorage(b.service)
|
||||||
|
storage["playlists"] = legacyStore
|
||||||
|
|
||||||
|
// enable dual writes if a RESTOptionsGetter is provided
|
||||||
|
if optsGetter != nil {
|
||||||
|
store, err := newStorage(scheme, optsGetter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
storage["playlists"] = grafanarest.NewDualWriter(legacyStore, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiGroupInfo.VersionedResourcesStorageMap[VersionID] = storage
|
apiGroupInfo.VersionedResourcesStorageMap[VersionID] = storage
|
||||||
return &apiGroupInfo
|
return &apiGroupInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package v0alpha1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apiserver/pkg/registry/generic"
|
||||||
|
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||||
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
|
grafanaregistry "github.com/grafana/grafana/pkg/services/grafana-apiserver/registry/generic"
|
||||||
|
grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ grafanarest.Storage = (*storage)(nil)
|
||||||
|
|
||||||
|
type storage struct {
|
||||||
|
*genericregistry.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) {
|
||||||
|
strategy := grafanaregistry.NewStrategy(scheme)
|
||||||
|
|
||||||
|
store := &genericregistry.Store{
|
||||||
|
NewFunc: func() runtime.Object { return &Playlist{} },
|
||||||
|
NewListFunc: func() runtime.Object { return &PlaylistList{} },
|
||||||
|
PredicateFunc: grafanaregistry.Matcher,
|
||||||
|
DefaultQualifiedResource: Resource("playlists"),
|
||||||
|
SingularQualifiedResource: Resource("playlist"),
|
||||||
|
|
||||||
|
CreateStrategy: strategy,
|
||||||
|
UpdateStrategy: strategy,
|
||||||
|
DeleteStrategy: strategy,
|
||||||
|
|
||||||
|
TableConvertor: rest.NewDefaultTableConvertor(Resource("playlists")),
|
||||||
|
}
|
||||||
|
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs}
|
||||||
|
if err := store.CompleteWithOptions(options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &storage{Store: store}, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package v1
|
package v0alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
+4
-6
@@ -1,11 +1,9 @@
|
|||||||
//go:build !ignore_autogenerated
|
//go:build !ignore_autogenerated
|
||||||
// +build !ignore_autogenerated
|
// +build !ignore_autogenerated
|
||||||
|
|
||||||
// generated by scripts/k8s/update-codegen.sh
|
|
||||||
|
|
||||||
// Code generated by deepcopy-gen. DO NOT EDIT.
|
// Code generated by deepcopy-gen. DO NOT EDIT.
|
||||||
|
|
||||||
package v1
|
package v0alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
@@ -71,17 +69,17 @@ func (in *PlaylistList) DeepCopyObject() runtime.Object {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *handler) DeepCopyInto(out *handler) {
|
func (in *legacyStorage) DeepCopyInto(out *legacyStorage) {
|
||||||
*out = *in
|
*out = *in
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Storage.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Storage.
|
||||||
func (in *handler) DeepCopy() *handler {
|
func (in *legacyStorage) DeepCopy() *legacyStorage {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := new(handler)
|
out := new(legacyStorage)
|
||||||
in.DeepCopyInto(out)
|
in.DeepCopyInto(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
|
||||||
"k8s.io/apiserver/pkg/endpoints/request"
|
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
|
||||||
|
|
||||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/playlist"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ rest.Scoper = (*handler)(nil)
|
|
||||||
var _ rest.SingularNameProvider = (*handler)(nil)
|
|
||||||
var _ rest.Getter = (*handler)(nil)
|
|
||||||
var _ rest.Lister = (*handler)(nil)
|
|
||||||
var _ rest.Storage = (*handler)(nil)
|
|
||||||
|
|
||||||
type handler struct {
|
|
||||||
service playlist.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) New() runtime.Object {
|
|
||||||
return &Playlist{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) Destroy() {}
|
|
||||||
|
|
||||||
func (r *handler) NamespaceScoped() bool {
|
|
||||||
return true // namespace == org
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) GetSingularName() string {
|
|
||||||
return "playlist"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) NewList() runtime.Object {
|
|
||||||
return &PlaylistList{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
|
||||||
return rest.NewDefaultTableConvertor(Resource("playlists")).ConvertToTable(ctx, object, tableOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
|
||||||
ns, ok := request.NamespaceFrom(ctx)
|
|
||||||
if !ok || ns == "" {
|
|
||||||
return nil, fmt.Errorf("namespace required")
|
|
||||||
}
|
|
||||||
|
|
||||||
orgId, err := grafanaapiserver.NamespaceToOrgID(ns)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 100
|
|
||||||
if options.Limit > 0 {
|
|
||||||
limit = int(options.Limit)
|
|
||||||
}
|
|
||||||
res, err := r.service.Search(ctx, &playlist.GetPlaylistsQuery{
|
|
||||||
OrgId: orgId,
|
|
||||||
Limit: limit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
list := &PlaylistList{
|
|
||||||
TypeMeta: metav1.TypeMeta{
|
|
||||||
Kind: "PlaylistList",
|
|
||||||
APIVersion: APIVersion,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, v := range res {
|
|
||||||
p := Playlist{
|
|
||||||
TypeMeta: metav1.TypeMeta{
|
|
||||||
Kind: "Playlist",
|
|
||||||
APIVersion: APIVersion,
|
|
||||||
},
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: v.UID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
p.Name = v.Name + " // " + v.Interval
|
|
||||||
list.Items = append(list.Items, p)
|
|
||||||
// TODO?? if table... we don't need the body of each, otherwise full lookup!
|
|
||||||
}
|
|
||||||
if len(list.Items) == limit {
|
|
||||||
list.Continue = "<more>" // TODO?
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *handler) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
|
||||||
ns, ok := request.NamespaceFrom(ctx)
|
|
||||||
if !ok || ns == "" {
|
|
||||||
return nil, fmt.Errorf("namespace required")
|
|
||||||
}
|
|
||||||
|
|
||||||
orgId, err := grafanaapiserver.NamespaceToOrgID(ns)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := r.service.Get(ctx, &playlist.GetPlaylistByUidQuery{
|
|
||||||
UID: name,
|
|
||||||
OrgId: orgId,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if p == nil {
|
|
||||||
return nil, fmt.Errorf("not found?")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Playlist{
|
|
||||||
TypeMeta: metav1.TypeMeta{
|
|
||||||
Kind: "Playlist",
|
|
||||||
APIVersion: APIVersion,
|
|
||||||
},
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: p.Uid,
|
|
||||||
},
|
|
||||||
Name: p.Name + "//" + p.Interval,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+3
-2
@@ -2,9 +2,10 @@ package apis
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
playlistv1 "github.com/grafana/grafana/pkg/apis/playlist/v1"
|
|
||||||
|
playlistsv0alpha1 "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var WireSet = wire.NewSet(
|
var WireSet = wire.NewSet(
|
||||||
playlistv1.RegisterAPIService,
|
playlistsv0alpha1.RegisterAPIService,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package apiregistry
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
playlistsv1 "github.com/grafana/grafana/pkg/apis/playlist/v1"
|
playlistsv0alpha1 "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ var (
|
|||||||
type Service struct{}
|
type Service struct{}
|
||||||
|
|
||||||
func ProvideService(
|
func ProvideService(
|
||||||
_ *playlistsv1.PlaylistAPIBuilder,
|
_ *playlistsv0alpha1.PlaylistAPIBuilder,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{}
|
return &Service{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Grafana Kubernetes compatible API Server
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
```ini
|
||||||
|
app_mode = development
|
||||||
|
|
||||||
|
[feature_toggles]
|
||||||
|
grafanaAPIServer = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Grafana:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable dual write to `etcd`
|
||||||
|
|
||||||
|
Start `etcd`:
|
||||||
|
```bash
|
||||||
|
make devenv sources=etcd
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable dual write to `etcd`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[grafana-apiserver]
|
||||||
|
etcd_servers = 127.0.0.1:2379
|
||||||
|
```
|
||||||
|
|
||||||
|
### `kubectl` access
|
||||||
|
|
||||||
|
From the root of the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=$PWD/data/k8s/grafana.kubeconfig
|
||||||
|
kubectl api-resources
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grafana API Access
|
||||||
|
|
||||||
|
The Kubernetes compatible API can be accessed using existing Grafana AuthN at: [http://localhost:3000/k8s/apis/](http://localhost:3000/k8s/apis/).
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
package grafanaapiserver
|
package grafanaapiserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||||
|
"k8s.io/apiserver/pkg/registry/generic"
|
||||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||||
"k8s.io/kube-openapi/pkg/common"
|
"k8s.io/kube-openapi/pkg/common"
|
||||||
"k8s.io/kube-openapi/pkg/spec3"
|
"k8s.io/kube-openapi/pkg/spec3"
|
||||||
@@ -22,7 +19,8 @@ type APIGroupBuilder interface {
|
|||||||
GetAPIGroupInfo(
|
GetAPIGroupInfo(
|
||||||
scheme *runtime.Scheme,
|
scheme *runtime.Scheme,
|
||||||
codecs serializer.CodecFactory, // pointer?
|
codecs serializer.CodecFactory, // pointer?
|
||||||
) *genericapiserver.APIGroupInfo
|
optsGetter generic.RESTOptionsGetter,
|
||||||
|
) (*genericapiserver.APIGroupInfo, error)
|
||||||
|
|
||||||
// Get OpenAPI definitions
|
// Get OpenAPI definitions
|
||||||
GetOpenAPIDefinitions() common.GetOpenAPIDefinitions
|
GetOpenAPIDefinitions() common.GetOpenAPIDefinitions
|
||||||
@@ -30,34 +28,3 @@ type APIGroupBuilder interface {
|
|||||||
// Register additional routes with the server
|
// Register additional routes with the server
|
||||||
GetOpenAPIPostProcessor() func(*spec3.OpenAPI) (*spec3.OpenAPI, error)
|
GetOpenAPIPostProcessor() func(*spec3.OpenAPI) (*spec3.OpenAPI, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OrgIdToNamespace(orgId int64) string {
|
|
||||||
if orgId > 1 {
|
|
||||||
return fmt.Sprintf("org-%d", orgId)
|
|
||||||
}
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
func NamespaceToOrgID(ns string) (int64, error) {
|
|
||||||
parts := strings.Split(ns, "-")
|
|
||||||
switch len(parts) {
|
|
||||||
case 1:
|
|
||||||
if parts[0] == "default" {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
if parts[0] == "" {
|
|
||||||
return 0, nil // no orgId, cluster scope
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("invalid namespace (expected default)")
|
|
||||||
case 2:
|
|
||||||
if !(parts[0] == "org" || parts[0] == "tenant") {
|
|
||||||
return 0, fmt.Errorf("invalid namespace (org|tenant)")
|
|
||||||
}
|
|
||||||
n, err := strconv.ParseInt(parts[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid namepscae (%w)", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("invalid namespace (%d parts)", len(parts))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package request
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"k8s.io/apiserver/pkg/endpoints/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OrgIDFrom(ctx context.Context) (int64, bool) {
|
||||||
|
ns := request.NamespaceValue(ctx)
|
||||||
|
if len(ns) < 5 || ns[:4] != "org-" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID, err := strconv.Atoi(ns[4:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(orgID), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package request_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"k8s.io/apiserver/pkg/endpoints/request"
|
||||||
|
|
||||||
|
grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrgIDFrom(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
expected int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty namespace",
|
||||||
|
ctx: context.Background(),
|
||||||
|
expected: 0,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incorrect number of parts",
|
||||||
|
ctx: request.WithNamespace(context.Background(), "org-123-a"),
|
||||||
|
expected: 0,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incorrect prefix",
|
||||||
|
ctx: request.WithNamespace(context.Background(), "abc-123"),
|
||||||
|
expected: 0,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "org id not a number",
|
||||||
|
ctx: request.WithNamespace(context.Background(), "org-invalid"),
|
||||||
|
expected: 0,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid org id",
|
||||||
|
ctx: request.WithNamespace(context.Background(), "org-123"),
|
||||||
|
expected: 123,
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
actual, ok := grafanarequest.OrgIDFrom(tt.ctx)
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("OrgIDFrom() returned %d, expected %d", actual, tt.expected)
|
||||||
|
}
|
||||||
|
if ok != tt.ok {
|
||||||
|
t.Errorf("OrgIDFrom() returned %t, expected %t", ok, tt.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package generic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
"k8s.io/apimachinery/pkg/fields"
|
||||||
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
"k8s.io/apiserver/pkg/storage"
|
||||||
|
"k8s.io/apiserver/pkg/storage/names"
|
||||||
|
)
|
||||||
|
|
||||||
|
type genericStrategy struct {
|
||||||
|
runtime.ObjectTyper
|
||||||
|
names.NameGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStrategy creates and returns a genericStrategy instance.
|
||||||
|
func NewStrategy(typer runtime.ObjectTyper) genericStrategy {
|
||||||
|
return genericStrategy{typer, names.SimpleNameGenerator}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamespaceScoped returns true because all Generic resources must be within a namespace.
|
||||||
|
func (genericStrategy) NamespaceScoped() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (genericStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {}
|
||||||
|
|
||||||
|
func (genericStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {}
|
||||||
|
|
||||||
|
func (genericStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
|
||||||
|
return field.ErrorList{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarningsOnCreate returns warnings for the creation of the given object.
|
||||||
|
func (genericStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string { return nil }
|
||||||
|
|
||||||
|
func (genericStrategy) AllowCreateOnUpdate() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (genericStrategy) AllowUnconditionalUpdate() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (genericStrategy) Canonicalize(obj runtime.Object) {}
|
||||||
|
|
||||||
|
func (genericStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
|
||||||
|
return field.ErrorList{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarningsOnUpdate returns warnings for the given update.
|
||||||
|
func (genericStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAttrs returns labels and fields of an object.
|
||||||
|
func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
|
||||||
|
accessor, err := meta.Accessor(obj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
fieldsSet := fields.Set{
|
||||||
|
"metadata.name": accessor.GetName(),
|
||||||
|
}
|
||||||
|
return labels.Set(accessor.GetLabels()), fieldsSet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matcher returns a generic.SelectionPredicate that matches on label and field selectors.
|
||||||
|
func Matcher(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
|
||||||
|
return storage.SelectionPredicate{
|
||||||
|
Label: label,
|
||||||
|
Field: field,
|
||||||
|
GetAttrs: GetAttrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
metainternalversion "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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ rest.Storage = (*DualWriter)(nil)
|
||||||
|
_ rest.Scoper = (*DualWriter)(nil)
|
||||||
|
_ rest.TableConvertor = (*DualWriter)(nil)
|
||||||
|
_ rest.CreaterUpdater = (*DualWriter)(nil)
|
||||||
|
_ rest.CollectionDeleter = (*DualWriter)(nil)
|
||||||
|
_ rest.GracefulDeleter = (*DualWriter)(nil)
|
||||||
|
_ rest.SingularNameProvider = (*DualWriter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage is a storage implementation that satisfies the same interfaces as genericregistry.Store.
|
||||||
|
type Storage interface {
|
||||||
|
rest.Storage
|
||||||
|
rest.StandardStorage
|
||||||
|
rest.Scoper
|
||||||
|
rest.TableConvertor
|
||||||
|
rest.SingularNameProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyStorage is a storage implementation that writes to the Grafana SQL database.
|
||||||
|
type LegacyStorage interface {
|
||||||
|
rest.Storage
|
||||||
|
rest.Scoper
|
||||||
|
rest.SingularNameProvider
|
||||||
|
rest.TableConvertor
|
||||||
|
}
|
||||||
|
|
||||||
|
// DualWriter is a storage implementation that writes first to LegacyStorage and then to Storage.
|
||||||
|
// If writing to LegacyStorage fails, the write to Storage is skipped and the error is returned.
|
||||||
|
// Storage is used for all read operations.
|
||||||
|
//
|
||||||
|
// The LegacyStorage implementation must implement the following interfaces:
|
||||||
|
// - rest.Storage
|
||||||
|
// - rest.TableConvertor
|
||||||
|
// - rest.Scoper
|
||||||
|
// - rest.SingularNameProvider
|
||||||
|
//
|
||||||
|
// These interfaces are optional, but they all should be implemented to fully support dual writes:
|
||||||
|
// - rest.Creater
|
||||||
|
// - rest.Updater
|
||||||
|
// - rest.GracefulDeleter
|
||||||
|
// - rest.CollectionDeleter
|
||||||
|
type DualWriter struct {
|
||||||
|
Storage
|
||||||
|
legacy LegacyStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDualWriter returns a new DualWriter.
|
||||||
|
func NewDualWriter(legacy LegacyStorage, storage Storage) *DualWriter {
|
||||||
|
return &DualWriter{
|
||||||
|
Storage: storage,
|
||||||
|
legacy: legacy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create overrides the default behavior of the Storage and writes to both the LegacyStorage and Storage.
|
||||||
|
func (d *DualWriter) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||||
|
if legacy, ok := d.legacy.(rest.Creater); ok {
|
||||||
|
_, err := legacy.Create(ctx, obj, createValidation, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Storage.Create(ctx, obj, createValidation, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update overrides the default behavior of the Storage and writes to both the LegacyStorage and Storage.
|
||||||
|
func (d *DualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||||
|
if legacy, ok := d.legacy.(rest.Updater); ok {
|
||||||
|
_, _, err := legacy.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Storage.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete overrides the default behavior of the Storage and delete from both the LegacyStorage and Storage.
|
||||||
|
func (d *DualWriter) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||||
|
if legacy, ok := d.legacy.(rest.GracefulDeleter); ok {
|
||||||
|
_, _, err := legacy.Delete(ctx, name, deleteValidation, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Storage.Delete(ctx, name, deleteValidation, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollection overrides the default behavior of the Storage and delete from both the LegacyStorage and Storage.
|
||||||
|
func (d *DualWriter) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
|
||||||
|
if legacy, ok := d.legacy.(rest.CollectionDeleter); ok {
|
||||||
|
_, err := legacy.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.Storage.DeleteCollection(ctx, deleteValidation, options, listOptions)
|
||||||
|
}
|
||||||
@@ -90,7 +90,8 @@ type RestConfigProvider interface {
|
|||||||
type service struct {
|
type service struct {
|
||||||
*services.BasicService
|
*services.BasicService
|
||||||
|
|
||||||
restConfig *clientrest.Config
|
restConfig *clientrest.Config
|
||||||
|
etcd_servers []string
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
dataPath string
|
dataPath string
|
||||||
@@ -102,15 +103,17 @@ type service struct {
|
|||||||
builders []APIGroupBuilder
|
builders []APIGroupBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideService(cfg *setting.Cfg,
|
func ProvideService(
|
||||||
|
cfg *setting.Cfg,
|
||||||
rr routing.RouteRegister,
|
rr routing.RouteRegister,
|
||||||
) (*service, error) {
|
) (*service, error) {
|
||||||
s := &service{
|
s := &service{
|
||||||
enabled: cfg.IsFeatureToggleEnabled(featuremgmt.FlagGrafanaAPIServer),
|
etcd_servers: cfg.SectionWithEnvOverrides("grafana-apiserver").Key("etcd_servers").Strings(","),
|
||||||
rr: rr,
|
enabled: cfg.IsFeatureToggleEnabled(featuremgmt.FlagGrafanaAPIServer),
|
||||||
dataPath: path.Join(cfg.DataPath, "k8s"),
|
rr: rr,
|
||||||
stopCh: make(chan struct{}),
|
dataPath: path.Join(cfg.DataPath, "k8s"),
|
||||||
builders: []APIGroupBuilder{},
|
stopCh: make(chan struct{}),
|
||||||
|
builders: []APIGroupBuilder{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will be used when running as a dskit service
|
// This will be used when running as a dskit service
|
||||||
@@ -170,9 +173,13 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
o.Authorization.RemoteKubeConfigFileOptional = true
|
o.Authorization.RemoteKubeConfigFileOptional = true
|
||||||
o.Authorization.AlwaysAllowPaths = []string{"*"}
|
o.Authorization.AlwaysAllowPaths = []string{"*"}
|
||||||
o.Authorization.AlwaysAllowGroups = []string{user.SystemPrivilegedGroup, "grafana"}
|
o.Authorization.AlwaysAllowGroups = []string{user.SystemPrivilegedGroup, "grafana"}
|
||||||
o.Etcd = nil
|
o.Etcd.StorageConfig.Transport.ServerList = s.etcd_servers
|
||||||
|
|
||||||
o.Admission = nil
|
o.Admission = nil
|
||||||
o.CoreAPI = nil
|
o.CoreAPI = nil
|
||||||
|
if len(o.Etcd.StorageConfig.Transport.ServerList) == 0 {
|
||||||
|
o.Etcd = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get the util to get the paths to pre-generated certs
|
// Get the util to get the paths to pre-generated certs
|
||||||
certUtil := certgenerator.CertUtil{
|
certUtil := certgenerator.CertUtil{
|
||||||
@@ -246,7 +253,11 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
|
|
||||||
// Install the API Group+version
|
// Install the API Group+version
|
||||||
for _, b := range builders {
|
for _, b := range builders {
|
||||||
err = server.InstallAPIGroup(b.GetAPIGroupInfo(Scheme, Codecs))
|
g, err := b.GetAPIGroupInfo(Scheme, Codecs, serverConfig.RESTOptionsGetter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = server.InstallAPIGroup(g)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user