mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
K8s: Add explicit table converter (#77098)
This commit is contained in:
@@ -22,8 +22,9 @@ var (
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
service playlist.Service
|
||||
namespacer namespaceMapper
|
||||
service playlist.Service
|
||||
namespacer namespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
@@ -45,7 +46,7 @@ func (s *legacyStorage) NewList() runtime.Object {
|
||||
}
|
||||
|
||||
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)
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
|
||||
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/grafana-apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/playlist"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
@@ -26,6 +30,7 @@ var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
|
||||
type PlaylistAPIBuilder struct {
|
||||
service playlist.Service
|
||||
namespacer namespaceMapper
|
||||
gv schema.GroupVersion
|
||||
}
|
||||
|
||||
func RegisterAPIService(p playlist.Service,
|
||||
@@ -35,22 +40,23 @@ func RegisterAPIService(p playlist.Service,
|
||||
builder := &PlaylistAPIBuilder{
|
||||
service: p,
|
||||
namespacer: getNamespaceMapper(cfg),
|
||||
gv: schema.GroupVersion{Group: GroupName, Version: VersionID},
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *PlaylistAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return SchemeGroupVersion
|
||||
return b.gv
|
||||
}
|
||||
|
||||
func (b *PlaylistAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(SchemeGroupVersion,
|
||||
scheme.AddKnownTypes(b.gv,
|
||||
&Playlist{},
|
||||
&PlaylistList{},
|
||||
)
|
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||
return scheme.SetVersionPriority(SchemeGroupVersion)
|
||||
metav1.AddToGroupVersion(scheme, b.gv)
|
||||
return scheme.SetVersionPriority(b.gv)
|
||||
}
|
||||
|
||||
func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
||||
@@ -64,12 +70,34 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
||||
legacyStore := &legacyStorage{
|
||||
service: b.service,
|
||||
namespacer: b.namespacer,
|
||||
tableConverter: utils.NewTableConverter(
|
||||
b.gv.WithResource("playlists").GroupResource(),
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Title", Type: "string", Format: "string", Description: "The playlist name"},
|
||||
{Name: "Interval", Type: "string", Format: "string", Description: "How often the playlist will update"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
func(obj runtime.Object) ([]interface{}, error) {
|
||||
m, ok := obj.(*Playlist)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected playlist")
|
||||
}
|
||||
return []interface{}{
|
||||
m.Name,
|
||||
m.Spec.Title,
|
||||
m.Spec.Interval,
|
||||
m.CreationTimestamp.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
},
|
||||
),
|
||||
}
|
||||
storage["playlists"] = legacyStore
|
||||
|
||||
// enable dual writes if a RESTOptionsGetter is provided
|
||||
if optsGetter != nil {
|
||||
store, err := newStorage(scheme, optsGetter)
|
||||
store, err := newStorage(scheme, optsGetter, b.gv)
|
||||
store.TableConvertor = legacyStore.tableConverter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,11 +115,3 @@ func (b *PlaylistAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinition
|
||||
func (b *PlaylistAPIBuilder) GetAPIRoutes() *grafanaapiserver.APIRoutes {
|
||||
return nil // no custom API routes
|
||||
}
|
||||
|
||||
// SchemeGroupVersion is group version used to register these objects
|
||||
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: VersionID}
|
||||
|
||||
// Resource takes an unqualified resource and returns a Group qualified GroupResource
|
||||
func Resource(resource string) schema.GroupResource {
|
||||
return SchemeGroupVersion.WithResource(resource).GroupResource()
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package v0alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"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"
|
||||
@@ -16,21 +16,19 @@ type storage struct {
|
||||
*genericregistry.Store
|
||||
}
|
||||
|
||||
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) {
|
||||
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, gv schema.GroupVersion) (*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"),
|
||||
DefaultQualifiedResource: gv.WithResource("playlists").GroupResource(),
|
||||
SingularQualifiedResource: gv.WithResource("playlist").GroupResource(),
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
)
|
||||
|
||||
// Based on https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/registry/rest/table.go
|
||||
type customTableConvertor struct {
|
||||
gr schema.GroupResource
|
||||
columns []metav1.TableColumnDefinition
|
||||
reader func(obj runtime.Object) ([]interface{}, error)
|
||||
}
|
||||
|
||||
func NewTableConverter(gr schema.GroupResource, columns []metav1.TableColumnDefinition, reader func(obj runtime.Object) ([]interface{}, error)) rest.TableConvertor {
|
||||
converter := customTableConvertor{
|
||||
gr: gr,
|
||||
columns: columns,
|
||||
reader: reader,
|
||||
}
|
||||
// Replace the description on standard columns with the global values
|
||||
for idx, column := range converter.columns {
|
||||
if column.Description == "" {
|
||||
switch column.Name {
|
||||
case "Name":
|
||||
converter.columns[idx].Description = swaggerMetadataDescriptions["name"]
|
||||
case "Created At":
|
||||
converter.columns[idx].Description = swaggerMetadataDescriptions["creationTimestamp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
return converter
|
||||
}
|
||||
|
||||
var _ rest.TableConvertor = &customTableConvertor{}
|
||||
var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc()
|
||||
|
||||
func (c customTableConvertor) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
table, ok := object.(*metav1.Table)
|
||||
if ok {
|
||||
return table, nil
|
||||
} else {
|
||||
table = &metav1.Table{}
|
||||
}
|
||||
fn := func(obj runtime.Object) error {
|
||||
cells, err := c.reader(obj)
|
||||
if err != nil {
|
||||
resource := c.gr
|
||||
if info, ok := request.RequestInfoFrom(ctx); ok {
|
||||
resource = schema.GroupResource{Group: info.APIGroup, Resource: info.Resource}
|
||||
}
|
||||
return errNotAcceptable{resource: resource}
|
||||
}
|
||||
table.Rows = append(table.Rows, metav1.TableRow{
|
||||
Cells: cells,
|
||||
Object: runtime.RawExtension{Object: obj},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case meta.IsListType(object):
|
||||
if err := meta.EachListItem(object, fn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
if err := fn(object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if m, err := meta.ListAccessor(object); err == nil {
|
||||
table.ResourceVersion = m.GetResourceVersion()
|
||||
table.Continue = m.GetContinue()
|
||||
table.RemainingItemCount = m.GetRemainingItemCount()
|
||||
} else {
|
||||
if m, err := meta.CommonAccessor(object); err == nil {
|
||||
table.ResourceVersion = m.GetResourceVersion()
|
||||
}
|
||||
}
|
||||
if opt, ok := tableOptions.(*metav1.TableOptions); !ok || !opt.NoHeaders {
|
||||
table.ColumnDefinitions = c.columns
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// errNotAcceptable indicates the resource doesn't support Table conversion
|
||||
type errNotAcceptable struct {
|
||||
resource schema.GroupResource
|
||||
}
|
||||
|
||||
func (e errNotAcceptable) Error() string {
|
||||
return fmt.Sprintf("the resource %s does not support being converted to a Table", e.resource)
|
||||
}
|
||||
|
||||
func (e errNotAcceptable) Status() metav1.Status {
|
||||
return metav1.Status{
|
||||
Status: metav1.StatusFailure,
|
||||
Code: http.StatusNotAcceptable,
|
||||
Reason: metav1.StatusReason("NotAcceptable"),
|
||||
Message: e.Error(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
func TestTableConverter(t *testing.T) {
|
||||
// dummy converter
|
||||
converter := utils.NewTableConverter(
|
||||
schema.GroupResource{Group: "x", Resource: "y"},
|
||||
[]metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Dummy", Type: "string", Format: "string", Description: "Something here"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
func(obj runtime.Object) ([]interface{}, error) {
|
||||
m, ok := obj.(*metav1.APIGroup)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected status")
|
||||
}
|
||||
ts := metav1.NewTime(time.UnixMilli(10000000))
|
||||
return []interface{}{
|
||||
m.Name,
|
||||
"dummy",
|
||||
ts.Time.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Convert a single table
|
||||
table, err := converter.ConvertToTable(context.Background(), &metav1.APIGroup{
|
||||
Name: "hello",
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
out, err := json.MarshalIndent(table, "", " ")
|
||||
require.NoError(t, err)
|
||||
//fmt.Printf("%s", string(out))
|
||||
require.JSONEq(t, `{
|
||||
"metadata": {},
|
||||
"columnDefinitions": [
|
||||
{
|
||||
"name": "Name",
|
||||
"type": "string",
|
||||
"format": "name",
|
||||
"description": "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "Dummy",
|
||||
"type": "string",
|
||||
"format": "string",
|
||||
"description": "Something here",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "Created At",
|
||||
"type": "date",
|
||||
"format": "",
|
||||
"description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"hello",
|
||||
"dummy",
|
||||
"1970-01-01T02:46:40Z"
|
||||
],
|
||||
"object": {
|
||||
"name": "hello",
|
||||
"versions": null,
|
||||
"preferredVersion": {
|
||||
"groupVersion": "",
|
||||
"version": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`, string(out))
|
||||
|
||||
// Convert something else
|
||||
table, err = converter.ConvertToTable(context.Background(), &metav1.Status{}, nil)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, table)
|
||||
require.Equal(t, "the resource y.x does not support being converted to a Table", err.Error())
|
||||
}
|
||||
Reference in New Issue
Block a user