From d2732ae7269b392bbb585cd473f135d82e1d342f Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 25 Oct 2023 09:00:20 -0700 Subject: [PATCH] K8s: Add explicit table converter (#77098) --- pkg/apis/playlist/v0alpha1/legacy_storage.go | 7 +- pkg/apis/playlist/v0alpha1/register.go | 46 +++++--- pkg/apis/playlist/v0alpha1/storage.go | 10 +- .../grafana-apiserver/utils/tableConverter.go | 109 ++++++++++++++++++ .../utils/tableConverter_test.go | 97 ++++++++++++++++ 5 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 pkg/services/grafana-apiserver/utils/tableConverter.go create mode 100644 pkg/services/grafana-apiserver/utils/tableConverter_test.go diff --git a/pkg/apis/playlist/v0alpha1/legacy_storage.go b/pkg/apis/playlist/v0alpha1/legacy_storage.go index debf40e386b..6537b1535ff 100644 --- a/pkg/apis/playlist/v0alpha1/legacy_storage.go +++ b/pkg/apis/playlist/v0alpha1/legacy_storage.go @@ -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) { diff --git a/pkg/apis/playlist/v0alpha1/register.go b/pkg/apis/playlist/v0alpha1/register.go index f0d67d18a30..758027f8772 100644 --- a/pkg/apis/playlist/v0alpha1/register.go +++ b/pkg/apis/playlist/v0alpha1/register.go @@ -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() -} diff --git a/pkg/apis/playlist/v0alpha1/storage.go b/pkg/apis/playlist/v0alpha1/storage.go index 73af722decc..f696e2d92ba 100644 --- a/pkg/apis/playlist/v0alpha1/storage.go +++ b/pkg/apis/playlist/v0alpha1/storage.go @@ -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 { diff --git a/pkg/services/grafana-apiserver/utils/tableConverter.go b/pkg/services/grafana-apiserver/utils/tableConverter.go new file mode 100644 index 00000000000..e4cc54823a6 --- /dev/null +++ b/pkg/services/grafana-apiserver/utils/tableConverter.go @@ -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(), + } +} diff --git a/pkg/services/grafana-apiserver/utils/tableConverter_test.go b/pkg/services/grafana-apiserver/utils/tableConverter_test.go new file mode 100644 index 00000000000..13bb62a71d8 --- /dev/null +++ b/pkg/services/grafana-apiserver/utils/tableConverter_test.go @@ -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()) +}