1
0
mirror of https://github.com/rancher/steve.git synced 2025-09-02 16:05:42 +00:00

Partial extension API server store + control over printed columns (#432)

* Checkpoint

* Add support for custom columns

* Remove old Store and Delegate abstraction

* Fix nits and rewording

* Remove unused mock file

* Update documentation for extension api server

* Remove the need for scheme for ConvertListOptions

* Rename store to utils

* fixup! Remove the need for scheme for ConvertListOptions

* Move watch helper to tests

* Add convertError at a few places

* Ignore misspell on creater

* Fix comments and remove unused params

* Add convertError to missing error returns

* Fix watcher implementation

* Document request.UserFrom and request.NamespaceFrom
This commit is contained in:
Tom Lebreux
2025-01-15 12:41:44 -05:00
committed by GitHub
parent 4477e2c1c4
commit fdf2ef8e93
13 changed files with 987 additions and 2071 deletions

View File

@@ -8,7 +8,6 @@ import (
"strings" "strings"
"sync" "sync"
"k8s.io/apimachinery/pkg/api/meta"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
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"
@@ -72,11 +71,11 @@ type ExtensionAPIServerOptions struct {
// //
// Use [NewExtensionAPIServer] to create an ExtensionAPIServer. // Use [NewExtensionAPIServer] to create an ExtensionAPIServer.
// //
// Use [InstallStore] to add a new resource store onto an existing ExtensionAPIServer. // Use [ExtensionAPIServer.Install] to add a new resource store onto an existing ExtensionAPIServer.
// Each resources will then be reachable via /apis/<group>/<version>/<resource> as // Each resources will then be reachable via /apis/<group>/<version>/<resource> as
// defined by the Kubernetes API. // defined by the Kubernetes API.
// //
// When Run() is called, a separate HTTPS server is started. This server is meant // When [ExtensionAPIServer.Run] is called, a separate HTTPS server is started. This server is meant
// for the main kube-apiserver to communicate with our extension API server. We // for the main kube-apiserver to communicate with our extension API server. We
// can expect the following requests from the main kube-apiserver: // can expect the following requests from the main kube-apiserver:
// //
@@ -201,34 +200,49 @@ func (s *ExtensionAPIServer) ServeHTTP(w http.ResponseWriter, req *http.Request)
s.handler.ServeHTTP(w, req) s.handler.ServeHTTP(w, req)
} }
// InstallStore installs a store on the given ExtensionAPIServer object. // GetAuthorizer returns the authorizer used by the extension server to authorize
// requests
// //
// t and TList must be non-nil. // This can be used to inject the authorizer in stores that need them.
// func (s *ExtensionAPIServer) GetAuthorizer() authorizer.Authorizer {
// Here's an example store for a Token and TokenList resource in the ext.cattle.io/v1 apiVersion: return s.authorizer
//
// gvk := schema.GroupVersionKind{
// Group: "ext.cattle.io",
// Version: "v1",
// Kind: "Token",
// }
// InstallStore(s, &Token{}, &TokenList{}, "tokens", "token", gvk, store)
//
// Note: Not using a method on ExtensionAPIServer object due to Go generic limitations.
func InstallStore[T runtime.Object, TList runtime.Object](
s *ExtensionAPIServer,
t T,
tList TList,
resourceName string,
singularName string,
gvk schema.GroupVersionKind,
store Store[T, TList],
) error {
if !meta.IsListType(tList) {
return fmt.Errorf("tList (%T) is not a list type", tList)
} }
// Install adds a new store to the extension API server.
//
// A store implements handlers for the various operations (verbs) supported for
// a defined GVK / GVR. For example, a store for a (apiVersion:
// ext.cattle.io/v1, kind: Tokens) Custom Resource could implement create and
// watch verbs.
//
// A store MUST implement the following interfaces: [rest.Storage], [rest.Scoper], [rest.GroupVersionKindProvider]
// and [rest.SingularNameProvider].
//
// Implementing the various verbs goes as follows:
// - get: [rest.Getter] must be implemented
// - list: [rest.Lister] must be implemented. To help implement table conversion, we provide [ConvertToTable] and [ConvertToTableDefault].
// Use [ConvertListOptions] to convert the [metainternalversion.ListOptions] to a [metav1.ListOptions].
// - watch: [rest.Watcher] must be implemented. Use [ConvertListOptions] to convert the [metainternalversion.ListOptions] to a [metav1.ListOptions].
// - create: [rest.Creater] must be implemented
// - update: [rest.Updater] must be implemented. To help implement this correctly with create-on-update support, we provide [CreateOrUpdate].
// - patch: [rest.Patcher] must be implemented, which is essentially [rest.Getter] and [rest.Updater]
// - delete: [rest.GracefulDeleter] must be implemented
// - deletecollection: [rest.CollectionDeleter] must be implemented
//
// Most of these methods have a [context.Context] parameter that can be used to get more information
// about the request. Here are some examples:
// - [request.UserFrom] to get the user info
// - [request.NamespaceFrom] to get the namespace (if applicable)
//
// For an example store implementing these, please look at the testStore type with the caveat that it is a dummy test-special purpose
// store.
//
// Note that errors returned by any operations above MUST be of type [k8s.io/apimachinery/pkg/api/errors.APIStatus].
// These can be created with [k8s.io/apimachinery/pkg/api/errors.NewNotFound], etc.
// If an error of unknown type is returned, the library will log an error message.
//
//nolint:misspell
func (s *ExtensionAPIServer) Install(resourceName string, gvk schema.GroupVersionKind, storage rest.Storage) error {
apiGroup, ok := s.apiGroups[gvk.Group] apiGroup, ok := s.apiGroups[gvk.Group]
if !ok { if !ok {
apiGroup = genericapiserver.NewDefaultAPIGroupInfo(gvk.Group, s.scheme, metav1.ParameterCodec, s.codecs) apiGroup = genericapiserver.NewDefaultAPIGroupInfo(gvk.Group, s.scheme, metav1.ParameterCodec, s.codecs)
@@ -239,25 +253,7 @@ func InstallStore[T runtime.Object, TList runtime.Object](
apiGroup.VersionedResourcesStorageMap[gvk.Version] = make(map[string]rest.Storage) apiGroup.VersionedResourcesStorageMap[gvk.Version] = make(map[string]rest.Storage)
} }
del := &delegateError[T, TList]{ apiGroup.VersionedResourcesStorageMap[gvk.Version][resourceName] = storage
inner: &delegate[T, TList]{
scheme: s.scheme,
t: t,
tList: tList,
singularName: singularName,
gvk: gvk,
gvr: schema.GroupVersionResource{
Group: gvk.Group,
Version: gvk.Version,
Resource: resourceName,
},
authorizer: s.authorizer,
store: store,
},
}
apiGroup.VersionedResourcesStorageMap[gvk.Version][resourceName] = del
s.apiGroups[gvk.Group] = apiGroup s.apiGroups[gvk.Group] = apiGroup
return nil return nil
} }

View File

@@ -1,6 +1,7 @@
package ext package ext
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -12,22 +13,32 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
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/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/server/options" "k8s.io/apiserver/pkg/server/options"
) )
var _ rest.Storage = (*authnTestStore)(nil)
var _ rest.Lister = (*authnTestStore)(nil)
type authnTestStore struct { type authnTestStore struct {
*testStore *testStore[*TestType, *TestTypeList]
userCh chan user.Info userCh chan user.Info
} }
func (t *authnTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { func (t *authnTestStore) List(ctx context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) {
t.userCh <- ctx.User userInfo, ok := request.UserFrom(ctx)
if !ok {
return nil, convertError(fmt.Errorf("missing user info"))
}
t.userCh <- userInfo
return &testTypeListFixture, nil return &testTypeListFixture, nil
} }
@@ -50,10 +61,10 @@ func TestAuthenticationCustom(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
store := &authnTestStore{ store := &authnTestStore{
testStore: &testStore{}, testStore: newDefaultTestStore(),
userCh: make(chan user.Info, 100), userCh: make(chan user.Info, 100),
} }
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) { opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) {

View File

@@ -23,32 +23,40 @@ import (
"go.uber.org/mock/gomock" "go.uber.org/mock/gomock"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
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/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
yamlutil "k8s.io/apimachinery/pkg/util/yaml" yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/server/options" "k8s.io/apiserver/pkg/server/options"
) )
type authzTestStore struct { type authzTestStore struct {
*testStore *testStore[*TestType, *TestTypeList]
authorizer authorizer.Authorizer
} }
func (t *authzTestStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) { // Get implements [rest.Getter]
if name == "not-found" { func (t *authzTestStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name) return t.get(ctx, name, options)
}
return t.testStore.Get(ctx, name, opts)
} }
func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { // List implements [rest.Lister]
if ctx.User.GetName() == "read-only-error" { func (t *authzTestStore) List(ctx context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) {
decision, _, err := ctx.Authorizer.Authorize(ctx, authorizer.AttributesRecord{ userInfo, ok := request.UserFrom(ctx)
User: ctx.User, if !ok {
return nil, convertError(fmt.Errorf("missing user info"))
}
if userInfo.GetName() == "read-only-error" {
decision, _, err := t.authorizer.Authorize(ctx, authorizer.AttributesRecord{
User: userInfo,
Verb: "customverb", Verb: "customverb",
Resource: "testtypes", Resource: "testtypes",
ResourceRequest: true, ResourceRequest: true,
@@ -58,7 +66,7 @@ func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeL
if err == nil { if err == nil {
err = fmt.Errorf("not allowed") err = fmt.Errorf("not allowed")
} }
forbidden := apierrors.NewForbidden(ctx.GroupVersionResource.GroupResource(), "Forbidden", err) forbidden := apierrors.NewForbidden(t.gvr.GroupResource(), "Forbidden", err)
forbidden.ErrStatus.Kind = "Status" forbidden.ErrStatus.Kind = "Status"
forbidden.ErrStatus.APIVersion = "v1" forbidden.ErrStatus.APIVersion = "v1"
return nil, forbidden return nil, forbidden
@@ -67,6 +75,54 @@ func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeL
return &testTypeListFixture, nil return &testTypeListFixture, nil
} }
func (t *authzTestStore) get(_ context.Context, name string, _ *metav1.GetOptions) (*TestType, error) {
if name == "not-found" {
return nil, apierrors.NewNotFound(t.gvr.GroupResource(), name)
}
return &testTypeFixture, nil
}
func (t *authzTestStore) create(_ context.Context, _ *TestType, _ *metav1.CreateOptions) (*TestType, error) {
return &testTypeFixture, nil
}
func (t *authzTestStore) update(_ context.Context, _ *TestType, _ *metav1.UpdateOptions) (*TestType, error) {
return &testTypeFixture, nil
}
// Create implements [rest.Creater]
func (t *authzTestStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if createValidation != nil {
err := createValidation(ctx, obj)
if err != nil {
return obj, err
}
}
objT, ok := obj.(*TestType)
if !ok {
var zeroT *TestType
return nil, convertError(fmt.Errorf("expected %T but got %T", zeroT, obj))
}
return t.create(ctx, objT, options)
}
// Update implements [rest.Updater]
func (t *authzTestStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return CreateOrUpdate(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options, t.get, t.create, t.update)
}
// Watch implements [rest.Watcher]
func (t *authzTestStore) Watch(_ context.Context, _ *metainternalversion.ListOptions) (watch.Interface, error) {
return nil, nil
}
// Delete implements [rest.GracefulDeleter]
func (t *authzTestStore) Delete(_ context.Context, _ string, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, nil
}
func (s *ExtensionAPIServerSuite) TestAuthorization() { func (s *ExtensionAPIServerSuite) TestAuthorization() {
t := s.T() t := s.T()
@@ -89,10 +145,7 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() {
ln, _, err := options.CreateListener("", ":0", net.ListenConfig{}) ln, _, err := options.CreateListener("", ":0", net.ListenConfig{})
require.NoError(t, err) require.NoError(t, err)
store := &authzTestStore{ extensionAPIServer, cleanup, err := setupExtensionAPIServerNoStore(t, scheme, func(opts *ExtensionAPIServerOptions) {
testStore: &testStore{},
}
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln opts.Listener = ln
opts.Authorizer = authz opts.Authorizer = authz
opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) { opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) {
@@ -104,7 +157,17 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() {
User: user, User: user,
}, true, nil }, true, nil
}) })
}, nil) }, func(s *ExtensionAPIServer) error {
store := &authzTestStore{
testStore: newDefaultTestStore(),
authorizer: s.GetAuthorizer(),
}
err := s.Install("testtypes", testTypeGV.WithKind("TestType"), store)
if err != nil {
return err
}
return nil
})
require.NoError(t, err) require.NoError(t, err)
defer cleanup() defer cleanup()

View File

@@ -11,11 +11,11 @@ import (
"net/http/httptest" "net/http/httptest"
"sort" "sort"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
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"
@@ -24,6 +24,7 @@ import (
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
regrest "k8s.io/apiserver/pkg/registry/rest"
"k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
) )
@@ -41,83 +42,6 @@ func authzAllowAll(ctx context.Context, a authorizer.Attributes) (authorizer.Dec
return authorizer.DecisionAllow, "", nil return authorizer.DecisionAllow, "", nil
} }
type mapStore struct {
items map[string]*TestType
events chan WatchEvent[*TestType]
}
func newMapStore() *mapStore {
return &mapStore{
items: make(map[string]*TestType),
events: make(chan WatchEvent[*TestType], 100),
}
}
func (t *mapStore) Create(ctx Context, obj *TestType, opts *metav1.CreateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; found {
return nil, apierrors.NewAlreadyExists(ctx.GroupVersionResource.GroupResource(), obj.Name)
}
t.items[obj.Name] = obj
t.events <- WatchEvent[*TestType]{
Event: watch.Added,
Object: obj,
}
return obj, nil
}
func (t *mapStore) Update(ctx Context, obj *TestType, opts *metav1.UpdateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; !found {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), obj.Name)
}
obj.ManagedFields = []metav1.ManagedFieldsEntry{}
t.items[obj.Name] = obj
t.events <- WatchEvent[*TestType]{
Event: watch.Modified,
Object: obj,
}
return obj, nil
}
func (t *mapStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) {
obj, found := t.items[name]
if !found {
return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
}
return obj, nil
}
func (t *mapStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) {
items := []TestType{}
for _, obj := range t.items {
items = append(items, *obj)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name > items[j].Name
})
list := &TestTypeList{
Items: items,
}
return list, nil
}
func (t *mapStore) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestType], error) {
return t.events, nil
}
func (t *mapStore) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error {
obj, found := t.items[name]
if !found {
return apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
}
delete(t.items, name)
t.events <- WatchEvent[*TestType]{
Event: watch.Deleted,
Object: obj,
}
return nil
}
func TestStore(t *testing.T) { func TestStore(t *testing.T) {
scheme := runtime.NewScheme() scheme := runtime.NewScheme()
AddToScheme(scheme) AddToScheme(scheme)
@@ -128,8 +52,10 @@ func TestStore(t *testing.T) {
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0") ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
require.NoError(t, err) require.NoError(t, err)
store := newMapStore() store := newDefaultTestStore()
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { store.items = make(map[string]*TestType)
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(authAsAdmin) opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
@@ -298,36 +224,48 @@ func TestStore(t *testing.T) {
} }
} }
var _ Store[*TestTypeOther, *TestTypeOtherList] = (*testStoreOther)(nil) // This store tests when there's only a subset of verbs supported
type partialStorage struct {
// This store is meant to be able to test many stores gvk schema.GroupVersionKind
type testStoreOther struct {
} }
func (t *testStoreOther) Create(ctx Context, obj *TestTypeOther, opts *metav1.CreateOptions) (*TestTypeOther, error) { // New implements [regrest.Storage]
return &testTypeOtherFixture, nil func (t *partialStorage) New() runtime.Object {
obj := &TestType{}
obj.GetObjectKind().SetGroupVersionKind(t.gvk)
return obj
} }
func (t *testStoreOther) Update(ctx Context, obj *TestTypeOther, opts *metav1.UpdateOptions) (*TestTypeOther, error) { // Destroy implements [regrest.Storage]
return &testTypeOtherFixture, nil func (t *partialStorage) Destroy() {
} }
func (t *testStoreOther) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestTypeOther, error) { // GetSingularName implements [regrest.SingularNameProvider]
return &testTypeOtherFixture, nil func (t *partialStorage) GetSingularName() string {
return "testtype"
} }
func (t *testStoreOther) List(ctx Context, opts *metav1.ListOptions) (*TestTypeOtherList, error) { // NamespaceScoped implements [regrest.Scoper]
return &testTypeOtherListFixture, nil func (t *partialStorage) NamespaceScoped() bool {
return false
}
// GroupVersionKind implements [regrest.GroupVersionKindProvider]
func (t *partialStorage) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
return t.gvk
}
func (s *partialStorage) Create(ctx context.Context, obj runtime.Object, createValidation regrest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if createValidation != nil {
err := createValidation(ctx, obj)
if err != nil {
return obj, err
}
} }
func (t *testStoreOther) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestTypeOther], error) {
return nil, nil return nil, nil
} }
func (t *testStoreOther) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error {
return nil
}
// The POC had a bug where multiple resources couldn't be installed so we're // The POC had a bug where multiple resources couldn't be installed so we're
// testing this here // testing this here
func TestDiscoveryAndOpenAPI(t *testing.T) { func TestDiscoveryAndOpenAPI(t *testing.T) {
@@ -343,35 +281,67 @@ func TestDiscoveryAndOpenAPI(t *testing.T) {
Group: "ext2.cattle.io", Group: "ext2.cattle.io",
Version: "v3", Version: "v3",
} }
partialGroupVersion := schema.GroupVersion{
Group: "ext.cattle.io",
Version: "v4",
}
scheme.AddKnownTypes(differentVersion, &TestType{}, &TestTypeList{}) scheme.AddKnownTypes(differentVersion, &TestType{}, &TestTypeList{})
scheme.AddKnownTypes(differentGroupVersion, &TestType{}, &TestTypeList{}) scheme.AddKnownTypes(differentGroupVersion, &TestType{}, &TestTypeList{})
scheme.AddKnownTypes(partialGroupVersion, &TestType{}, &TestTypeList{})
metav1.AddToGroupVersion(scheme, differentVersion) metav1.AddToGroupVersion(scheme, differentVersion)
metav1.AddToGroupVersion(scheme, differentGroupVersion) metav1.AddToGroupVersion(scheme, differentGroupVersion)
metav1.AddToGroupVersion(scheme, partialGroupVersion)
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0") ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
require.NoError(t, err) require.NoError(t, err)
store := &testStore{} store := newDefaultTestStore()
extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(authAsAdmin) opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
}, func(s *ExtensionAPIServer) error { }, func(s *ExtensionAPIServer) error {
store := &testStoreOther{} err = s.Install("testtypeothers", testTypeGV.WithKind("TestTypeOther"), &testStore[*TestTypeOther, *TestTypeOtherList]{
err := InstallStore(s, &TestTypeOther{}, &TestTypeOtherList{}, "testtypeothers", "testtypeother", testTypeGV.WithKind("TestTypeOther"), store) singular: "testtypeother",
objT: &TestTypeOther{},
objListT: &TestTypeOtherList{},
gvk: testTypeGV.WithKind("TestTypeOther"),
gvr: schema.GroupVersionResource{Group: testTypeGV.Group, Version: testTypeGV.Version, Resource: "testtypes"},
})
if err != nil { if err != nil {
return err return err
} }
err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentVersion.WithKind("TestType"), &testStore{}) err = s.Install("testtypes", differentVersion.WithKind("TestType"), &testStore[*TestType, *TestTypeList]{
singular: "testtype",
objT: &TestType{},
objListT: &TestTypeList{},
gvk: differentVersion.WithKind("TestType"),
gvr: schema.GroupVersionResource{Group: differentVersion.Group, Version: differentVersion.Version, Resource: "testtypes"},
})
if err != nil { if err != nil {
return err return err
} }
err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentGroupVersion.WithKind("TestType"), &testStore{}) err = s.Install("testtypes", differentGroupVersion.WithKind("TestType"), &testStore[*TestType, *TestTypeList]{
singular: "testtype",
objT: &TestType{},
objListT: &TestTypeList{},
gvk: differentGroupVersion.WithKind("TestType"),
gvr: schema.GroupVersionResource{Group: differentGroupVersion.Group, Version: differentVersion.Version, Resource: "testtypes"},
})
if err != nil { if err != nil {
return err return err
} }
err = s.Install("testtypes", partialGroupVersion.WithKind("TestType"), &partialStorage{
gvk: partialGroupVersion.WithKind("TestType"),
})
if err != nil {
return err
}
return nil return nil
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -401,6 +371,10 @@ func TestDiscoveryAndOpenAPI(t *testing.T) {
{ {
Name: "ext.cattle.io", Name: "ext.cattle.io",
Versions: []metav1.GroupVersionForDiscovery{ Versions: []metav1.GroupVersionForDiscovery{
{
GroupVersion: "ext.cattle.io/v4",
Version: "v4",
},
{ {
GroupVersion: "ext.cattle.io/v2", GroupVersion: "ext.cattle.io/v2",
Version: "v2", Version: "v2",
@@ -450,6 +424,10 @@ func TestDiscoveryAndOpenAPI(t *testing.T) {
GroupVersion: "ext.cattle.io/v2", GroupVersion: "ext.cattle.io/v2",
Version: "v2", Version: "v2",
}, },
{
GroupVersion: "ext.cattle.io/v4",
Version: "v4",
},
{ {
GroupVersion: "ext.cattle.io/v1", GroupVersion: "ext.cattle.io/v1",
Version: "v1", Version: "v1",
@@ -569,6 +547,32 @@ func TestDiscoveryAndOpenAPI(t *testing.T) {
}, },
}, },
}, },
{
path: "/apis/ext.cattle.io/v4",
got: &metav1.APIResourceList{},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.APIResourceList{
TypeMeta: metav1.TypeMeta{
Kind: "APIResourceList",
APIVersion: "v1",
},
GroupVersion: "ext.cattle.io/v4",
APIResources: []metav1.APIResource{
{
Name: "testtypes",
SingularName: "testtype",
Namespaced: false,
Kind: "TestType",
Group: "ext.cattle.io",
Version: "v4",
// Only the create verb is supported for this store
Verbs: metav1.Verbs{
"create",
},
},
},
},
},
{ {
path: "/openapi/v2", path: "/openapi/v2",
expectedStatusCode: http.StatusOK, expectedStatusCode: http.StatusOK,
@@ -664,15 +668,29 @@ func TestNoStore(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func setupExtensionAPIServer[ func setupExtensionAPIServer(
T runtime.Object, t *testing.T,
TList runtime.Object, scheme *runtime.Scheme,
]( store regrest.Storage,
optionSetter func(*ExtensionAPIServerOptions),
extensionAPIServerSetter func(*ExtensionAPIServer) error,
) (*ExtensionAPIServer, func(), error) {
fn := func(e *ExtensionAPIServer) error {
err := e.Install("testtypes", testTypeGV.WithKind("TestType"), store)
if err != nil {
return fmt.Errorf("InstallStore: %w", err)
}
if extensionAPIServerSetter != nil {
return extensionAPIServerSetter(e)
}
return nil
}
return setupExtensionAPIServerNoStore(t, scheme, optionSetter, fn)
}
func setupExtensionAPIServerNoStore(
t *testing.T, t *testing.T,
scheme *runtime.Scheme, scheme *runtime.Scheme,
objT T,
objTList TList,
store Store[T, TList],
optionSetter func(*ExtensionAPIServerOptions), optionSetter func(*ExtensionAPIServerOptions),
extensionAPIServerSetter func(*ExtensionAPIServer) error, extensionAPIServerSetter func(*ExtensionAPIServer) error,
) (*ExtensionAPIServer, func(), error) { ) (*ExtensionAPIServer, func(), error) {
@@ -694,11 +712,6 @@ func setupExtensionAPIServer[
return nil, func() {}, err return nil, func() {}, err
} }
err = InstallStore(extensionAPIServer, objT, objTList, "testtypes", "testtype", testTypeGV.WithKind("TestType"), store)
if err != nil {
return nil, func() {}, fmt.Errorf("InstallStore: %w", err)
}
if extensionAPIServerSetter != nil { if extensionAPIServerSetter != nil {
err = extensionAPIServerSetter(extensionAPIServer) err = extensionAPIServerSetter(extensionAPIServer)
if err != nil { if err != nil {
@@ -768,3 +781,222 @@ func createRecordingWatcher(scheme *runtime.Scheme, gvr schema.GroupVersionResou
stop: myWatch.Stop, stop: myWatch.Stop,
}, nil }, nil
} }
// This store tests the printed columns functionality
type customColumnsStore struct {
*testStore[*TestType, *TestTypeList]
lock sync.Mutex
columns []metav1.TableColumnDefinition
convertFn func(obj *TestType) []string
}
func (s *customColumnsStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
s.lock.Lock()
defer s.lock.Unlock()
return ConvertToTable(ctx, object, tableOptions, s.testStore.gvr.GroupResource(), s.columns, s.convertFn)
}
func (s *customColumnsStore) Set(columns []metav1.TableColumnDefinition, convertFn func(obj *TestType) []string) {
s.lock.Lock()
defer s.lock.Unlock()
s.columns = columns
s.convertFn = convertFn
}
func TestCustomColumns(t *testing.T) {
scheme := runtime.NewScheme()
AddToScheme(scheme)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
require.NoError(t, err)
store := &customColumnsStore{
testStore: newDefaultTestStore(),
}
extensionAPIServer, cleanup, err := setupExtensionAPIServerNoStore(t, scheme, func(opts *ExtensionAPIServerOptions) {
opts.Listener = ln
opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll)
opts.Authenticator = authenticator.RequestFunc(authAsAdmin)
}, func(s *ExtensionAPIServer) error {
err := s.Install("testtypes", testTypeGV.WithKind("TestType"), store)
if err != nil {
return err
}
return nil
})
require.NoError(t, err)
defer cleanup()
ts := httptest.NewServer(extensionAPIServer)
defer ts.Close()
createRequest := func(path string) *http.Request {
req := httptest.NewRequest(http.MethodGet, path, nil)
// This asks the apiserver to give back a metav1.Table for List and Get operations
req.Header.Add("Accept", "application/json;as=Table;v=v1;g=meta.k8s.io")
return req
}
columns := []metav1.TableColumnDefinition{
{
Name: "Name",
Type: "name",
},
{
Name: "Foo",
Type: "string",
},
{
Name: "Bar",
Type: "number",
},
}
convertFn := func(obj *TestType) []string {
return []string{
"the name is " + obj.GetName(),
"the foo value",
"the bar value",
}
}
tests := []struct {
name string
requests []*http.Request
columns []metav1.TableColumnDefinition
convertFn func(obj *TestType) []string
expectedStatusCode int
expectedBody any
}{
{
name: "default",
requests: []*http.Request{
createRequest("/apis/ext.cattle.io/v1/testtypes"),
createRequest("/apis/ext.cattle.io/v1/testtypes/foo"),
},
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.Table{
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
ColumnDefinitions: []metav1.TableColumnDefinition{
{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"},
{Name: "Created At", Type: "date", 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"},
},
Rows: []metav1.TableRow{
{
Cells: []any{"foo", "0001-01-01T00:00:00Z"},
Object: runtime.RawExtension{
Raw: []byte(`{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
},
},
},
},
},
{
name: "custom include object default and metadata",
requests: []*http.Request{
createRequest("/apis/ext.cattle.io/v1/testtypes"),
createRequest("/apis/ext.cattle.io/v1/testtypes/foo"),
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=Metadata"),
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=Metadata"),
},
columns: columns,
convertFn: convertFn,
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.Table{
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
ColumnDefinitions: []metav1.TableColumnDefinition{
{Name: "Name", Type: "name"},
{Name: "Foo", Type: "string"},
{Name: "Bar", Type: "number"},
},
Rows: []metav1.TableRow{
{
Cells: []any{"the name is foo", "the foo value", "the bar value"},
Object: runtime.RawExtension{
Raw: []byte(`{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
},
},
},
},
},
{
name: "custom include object None",
requests: []*http.Request{
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=None"),
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=None"),
},
columns: columns,
convertFn: convertFn,
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.Table{
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
ColumnDefinitions: []metav1.TableColumnDefinition{
{Name: "Name", Type: "name"},
{Name: "Foo", Type: "string"},
{Name: "Bar", Type: "number"},
},
Rows: []metav1.TableRow{
{
Cells: []any{"the name is foo", "the foo value", "the bar value"},
},
},
},
},
{
name: "custom include object Object",
requests: []*http.Request{
createRequest("/apis/ext.cattle.io/v1/testtypes?includeObject=Object"),
createRequest("/apis/ext.cattle.io/v1/testtypes/foo?includeObject=Object"),
},
columns: columns,
convertFn: convertFn,
expectedStatusCode: http.StatusOK,
expectedBody: &metav1.Table{
TypeMeta: metav1.TypeMeta{Kind: "Table", APIVersion: "meta.k8s.io/v1"},
ColumnDefinitions: []metav1.TableColumnDefinition{
{Name: "Name", Type: "name"},
{Name: "Foo", Type: "string"},
{Name: "Bar", Type: "number"},
},
Rows: []metav1.TableRow{
{
Cells: []any{"the name is foo", "the foo value", "the bar value"},
Object: runtime.RawExtension{
Raw: []byte(`{"kind":"TestType","apiVersion":"ext.cattle.io/v1","metadata":{"name":"foo","creationTimestamp":null}}`),
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.columns != nil {
store.Set(test.columns, test.convertFn)
}
for _, req := range test.requests {
w := httptest.NewRecorder()
extensionAPIServer.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, test.expectedStatusCode, resp.StatusCode)
if test.expectedBody != nil {
table := &metav1.Table{}
err = json.Unmarshal(body, table)
require.NoError(t, err)
require.Equal(t, test.expectedBody, table)
}
}
})
}
}

View File

@@ -1,356 +0,0 @@
package ext
import (
"context"
"errors"
"fmt"
"sync"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
)
var (
errMissingUserInfo = errors.New("missing user info")
)
// delegate is the bridge between k8s.io/apiserver's [rest.Storage] interface and
// our own Store interface we want developers to use
//
// It currently supports non-namespaced stores only because Store[T, TList] doesn't
// expose namespaces anywhere. When needed we'll add support to namespaced resources.
type delegate[T runtime.Object, TList runtime.Object] struct {
scheme *runtime.Scheme
// t is the resource of the delegate (eg: *Token) and must be non-nil.
t T
// tList is the resource list of the delegate (eg: *TokenList) and must be non-nil.
tList TList
gvk schema.GroupVersionKind
gvr schema.GroupVersionResource
singularName string
store Store[T, TList]
authorizer authorizer.Authorizer
}
// New implements [rest.Storage]
//
// It uses generics to create the resource and set its GVK.
func (s *delegate[T, TList]) New() runtime.Object {
t := s.t.DeepCopyObject()
t.GetObjectKind().SetGroupVersionKind(s.gvk)
return t
}
// Destroy cleans up its resources on shutdown.
// Destroy has to be implemented in thread-safe way and be prepared
// for being called more than once.
//
// It is NOT meant to delete resources from the backing storage. It is meant to
// stop clients, runners, etc that could be running for the store when the extension
// API server gracefully shutdowns/exits.
func (s *delegate[T, TList]) Destroy() {
}
// NewList implements [rest.Lister]
//
// It uses generics to create the resource and set its GVK.
func (s *delegate[T, TList]) NewList() runtime.Object {
tList := s.tList.DeepCopyObject()
tList.GetObjectKind().SetGroupVersionKind(s.gvk)
return tList
}
// List implements [rest.Lister]
func (s *delegate[T, TList]) List(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
options, err := s.convertListOptions(internaloptions)
if err != nil {
return nil, err
}
return s.store.List(ctx, options)
}
// ConvertToTable implements [rest.Lister]
//
// It converts an object or a list of objects to a table, which is used by kubectl
// (and Rancher UI) to display a table of the items.
//
// Currently, we use the default table convertor which will show two columns: Name and Created At.
func (s *delegate[T, TList]) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
defaultTableConverter := rest.NewDefaultTableConvertor(s.gvr.GroupResource())
return defaultTableConverter.ConvertToTable(ctx, object, tableOptions)
}
// Get implements [rest.Getter]
func (s *delegate[T, TList]) Get(parentCtx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
return s.store.Get(ctx, name, options)
}
// Delete implements [rest.GracefulDeleter]
//
// deleteValidation is used to do some validation on the object before deleting
// it in the store. For example, running mutating/validating webhooks, though we're not using these yet.
func (s *delegate[T, TList]) Delete(parentCtx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, false, err
}
oldObj, err := s.store.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
if deleteValidation != nil {
if err = deleteValidation(ctx, oldObj); err != nil {
return nil, false, err
}
}
err = s.store.Delete(ctx, name, options)
return oldObj, true, err
}
// Create implements [rest.Creater]
//
// createValidation is used to do some validation on the object before creating
// it in the store. For example, running mutating/validating webhooks, though we're not using these yet.
//
//nolint:misspell
func (s *delegate[T, TList]) Create(parentCtx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
if createValidation != nil {
err := createValidation(ctx, obj)
if err != nil {
return obj, err
}
}
tObj, ok := obj.(T)
if !ok {
return nil, fmt.Errorf("object was of type %T, not of expected type %T", obj, s.t)
}
return s.store.Create(ctx, tObj, options)
}
// Update implements [rest.Updater]
//
// createValidation is used to do some validation on the object before creating
// it in the store. For example, it will do an authorization check for "create"
// verb if the object needs to be created.
// See here for details: https://github.com/kubernetes/apiserver/blob/70ed6fdbea9eb37bd1d7558e90c20cfe888955e8/pkg/endpoints/handlers/update.go#L190-L201
// Another example is running mutating/validating webhooks, though we're not using these yet.
//
// updateValidation is used to do some validation on the object before updating it in the store.
// One example is running mutating/validating webhooks, though we're not using these yet.
func (s *delegate[T, TList]) Update(
parentCtx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
forceAllowCreate bool,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, false, err
}
oldObj, err := s.store.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
return nil, false, err
}
obj, err := objInfo.UpdatedObject(ctx, nil)
if err != nil {
return nil, false, err
}
if err = createValidation(ctx, obj); err != nil {
return nil, false, err
}
tObj, ok := obj.(T)
if !ok {
return nil, false, fmt.Errorf("object was of type %T, not of expected type %T", obj, s.t)
}
newObj, err := s.store.Create(ctx, tObj, &metav1.CreateOptions{})
if err != nil {
return nil, false, err
}
return newObj, true, err
}
newObj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return nil, false, err
}
newT, ok := newObj.(T)
if !ok {
return nil, false, fmt.Errorf("object was of type %T, not of expected type %T", newObj, s.t)
}
if updateValidation != nil {
err = updateValidation(ctx, newT, oldObj)
if err != nil {
return nil, false, err
}
}
newT, err = s.store.Update(ctx, newT, options)
if err != nil {
return nil, false, err
}
return newT, false, nil
}
type watcher struct {
closedLock sync.RWMutex
closed bool
ch chan watch.Event
}
func (w *watcher) Stop() {
w.closedLock.Lock()
defer w.closedLock.Unlock()
if !w.closed {
close(w.ch)
w.closed = true
}
}
func (w *watcher) addEvent(event watch.Event) bool {
w.closedLock.RLock()
defer w.closedLock.RUnlock()
if w.closed {
return false
}
w.ch <- event
return true
}
func (w *watcher) ResultChan() <-chan watch.Event {
return w.ch
}
func (s *delegate[T, TList]) Watch(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (watch.Interface, error) {
ctx, err := s.makeContext(parentCtx)
if err != nil {
return nil, err
}
options, err := s.convertListOptions(internaloptions)
if err != nil {
return nil, err
}
w := &watcher{
ch: make(chan watch.Event),
}
go func() {
// Not much point continuing the watch if the store stopped its watch.
// Double stopping here is fine.
defer w.Stop()
// Closing eventCh is the responsibility of the store.Watch method
// to avoid the store panicking while trying to send to a close channel
eventCh, err := s.store.Watch(ctx, options)
if err != nil {
return
}
for event := range eventCh {
added := w.addEvent(watch.Event{
Type: event.Event,
Object: event.Object,
})
if !added {
break
}
}
}()
return w, nil
}
// GroupVersionKind implements rest.GroupVersionKind
//
// This is used to generate the data for the Discovery API
func (s *delegate[T, TList]) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
return s.gvk
}
// NamespaceScoped implements rest.Scoper
//
// The delegate is used for non-namespaced resources so it always returns false
func (s *delegate[T, TList]) NamespaceScoped() bool {
return false
}
// Kind implements rest.KindProvider
//
// XXX: Example where / how this is used
func (s *delegate[T, TList]) Kind() string {
return s.gvk.Kind
}
// GetSingularName implements rest.SingularNameProvider
//
// This is used by a variety of things such as kubectl to map singular name to
// resource name. (eg: token => tokens)
func (s *delegate[T, TList]) GetSingularName() string {
return s.singularName
}
func (s *delegate[T, TList]) makeContext(parentCtx context.Context) (Context, error) {
userInfo, ok := request.UserFrom(parentCtx)
if !ok {
return Context{}, errMissingUserInfo
}
ctx := Context{
Context: parentCtx,
User: userInfo,
Authorizer: s.authorizer,
GroupVersionResource: s.gvr,
}
return ctx, nil
}
func (s *delegate[T, TList]) convertListOptions(options *metainternalversion.ListOptions) (*metav1.ListOptions, error) {
var out metav1.ListOptions
err := s.scheme.Convert(options, &out, nil)
if err != nil {
return nil, fmt.Errorf("convert list options: %w", err)
}
return &out, nil
}

View File

@@ -1,110 +0,0 @@
package ext
import (
"context"
"k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/registry/rest"
)
// delegateError wraps an inner delegate and converts unknown errors.
type delegateError[T runtime.Object, TList runtime.Object] struct {
inner *delegate[T, TList]
}
func (d *delegateError[T, TList]) New() runtime.Object {
return d.inner.New()
}
func (d *delegateError[T, TList]) Destroy() {
d.inner.Destroy()
}
func (d *delegateError[T, TList]) NewList() runtime.Object {
return d.inner.NewList()
}
func (d *delegateError[T, TList]) List(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (runtime.Object, error) {
result, err := d.inner.List(parentCtx, internaloptions)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
func (d *delegateError[T, TList]) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
result, err := d.inner.ConvertToTable(ctx, object, tableOptions)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
func (d *delegateError[T, TList]) Get(parentCtx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
result, err := d.inner.Get(parentCtx, name, options)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
func (d *delegateError[T, TList]) Delete(parentCtx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
result, completed, err := d.inner.Delete(parentCtx, name, deleteValidation, options)
if err != nil {
return nil, false, convertError(err)
}
return result, completed, nil
}
func (d *delegateError[T, TList]) Create(parentCtx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
result, err := d.inner.Create(parentCtx, obj, createValidation, options)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
func (d *delegateError[T, TList]) Update(parentCtx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
result, created, err := d.inner.Update(parentCtx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
if err != nil {
return nil, false, convertError(err)
}
return result, created, nil
}
func (d *delegateError[T, TList]) Watch(parentCtx context.Context, internaloptions *metainternalversion.ListOptions) (watch.Interface, error) {
result, err := d.inner.Watch(parentCtx, internaloptions)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
func (d *delegateError[T, TList]) GroupVersionKind(groupVersion schema.GroupVersion) schema.GroupVersionKind {
return d.inner.GroupVersionKind(groupVersion)
}
func (d *delegateError[T, TList]) NamespaceScoped() bool {
return d.inner.NamespaceScoped()
}
func (d *delegateError[T, TList]) Kind() string {
return d.inner.Kind()
}
func (d *delegateError[T, TList]) GetSingularName() string {
return d.inner.GetSingularName()
}
func convertError(err error) error {
if _, ok := err.(errors.APIStatus); ok {
return err
}
return errors.NewInternalError(err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,17 @@
package ext package ext
import ( import (
"context"
"fmt"
"sort"
"sync"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
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/watch"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
common "k8s.io/kube-openapi/pkg/common" common "k8s.io/kube-openapi/pkg/common"
spec "k8s.io/kube-openapi/pkg/validation/spec" spec "k8s.io/kube-openapi/pkg/validation/spec"
@@ -162,33 +170,239 @@ func (t *TestTypeOther) DeepCopyObject() runtime.Object {
return t return t
} }
var _ Store[*TestType, *TestTypeList] = (*testStore)(nil) var _ rest.Storage = (*testStore[*TestType, *TestTypeList])(nil)
var _ rest.Lister = (*testStore[*TestType, *TestTypeList])(nil)
var _ rest.GracefulDeleter = (*testStore[*TestType, *TestTypeList])(nil)
var _ rest.Creater = (*testStore[*TestType, *TestTypeList])(nil)
var _ rest.Updater = (*testStore[*TestType, *TestTypeList])(nil)
var _ rest.Getter = (*testStore[*TestType, *TestTypeList])(nil)
type testStore struct { type testStore[T runtime.Object, TList runtime.Object] struct {
singular string
objT T
objListT TList
gvk schema.GroupVersionKind
gvr schema.GroupVersionResource
// lock protects both items and watcher
lock sync.Mutex
items map[string]*TestType
watcher *watcher
} }
func (t *testStore) Create(ctx Context, obj *TestType, opts *metav1.CreateOptions) (*TestType, error) { func newDefaultTestStore() *testStore[*TestType, *TestTypeList] {
return &testTypeFixture, nil return &testStore[*TestType, *TestTypeList]{
singular: "testtype",
objT: &TestType{},
objListT: &TestTypeList{},
gvk: testTypeGV.WithKind("TestType"),
gvr: schema.GroupVersionResource{Group: testTypeGV.Group, Version: testTypeGV.Version, Resource: "testtypes"},
items: map[string]*TestType{
testTypeFixture.Name: &testTypeFixture,
},
}
} }
func (t *testStore) Update(ctx Context, obj *TestType, opts *metav1.UpdateOptions) (*TestType, error) { // New implements [rest.Storage]
return &testTypeFixture, nil func (t *testStore[T, TList]) New() runtime.Object {
obj := t.objT.DeepCopyObject()
obj.GetObjectKind().SetGroupVersionKind(t.gvk)
return obj
} }
func (t *testStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) { // GetSingularName implements [rest.SingularNameProvider]
return &testTypeFixture, nil func (t *testStore[T, TList]) GetSingularName() string {
return t.singular
} }
func (t *testStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { // NamespaceScoped implements [rest.Scoper]
return &testTypeListFixture, nil func (t *testStore[T, TList]) NamespaceScoped() bool {
return false
} }
func (t *testStore) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestType], error) { // GroupVersionKind implements [rest.GroupVersionKindProvider]
return nil, nil func (t *testStore[T, TList]) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind {
return t.gvk
} }
func (t *testStore) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error { // Destroy implements [rest.Storage]
return nil func (t *testStore[T, TList]) Destroy() {
}
// Get implements [rest.Getter]
func (t *testStore[T, TList]) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
t.lock.Lock()
defer t.lock.Unlock()
return t.get(ctx, name, options)
}
// Create implements [rest.Creater]
func (t *testStore[T, TList]) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
t.lock.Lock()
defer t.lock.Unlock()
if createValidation != nil {
err := createValidation(ctx, obj)
if err != nil {
return obj, err
}
}
objT, ok := obj.(*TestType)
if !ok {
var zeroT T
return nil, convertError(fmt.Errorf("expected %T but got %T", zeroT, obj))
}
return t.create(ctx, objT, options)
}
// Update implements [rest.Updater]
func (t *testStore[T, TList]) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
t.lock.Lock()
defer t.lock.Unlock()
return CreateOrUpdate(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options, t.get, t.create, t.update)
}
func (t *testStore[T, TList]) get(_ context.Context, name string, _ *metav1.GetOptions) (*TestType, error) {
obj, found := t.items[name]
if !found {
return nil, apierrors.NewNotFound(t.gvr.GroupResource(), name)
}
return obj, nil
}
func (t *testStore[T, TList]) create(_ context.Context, obj *TestType, _ *metav1.CreateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; found {
return nil, apierrors.NewAlreadyExists(t.gvr.GroupResource(), obj.Name)
}
t.items[obj.Name] = obj
t.addEventLocked(watch.Event{
Type: watch.Added,
Object: obj,
})
return obj, nil
}
func (t *testStore[T, TList]) update(_ context.Context, obj *TestType, _ *metav1.UpdateOptions) (*TestType, error) {
if _, found := t.items[obj.Name]; !found {
return nil, apierrors.NewNotFound(t.gvr.GroupResource(), obj.Name)
}
obj.ManagedFields = []metav1.ManagedFieldsEntry{}
t.items[obj.Name] = obj
t.addEventLocked(watch.Event{
Type: watch.Modified,
Object: obj,
})
return obj, nil
}
// NewList implements [rest.Lister]
func (t *testStore[T, TList]) NewList() runtime.Object {
objList := t.objListT.DeepCopyObject()
objList.GetObjectKind().SetGroupVersionKind(t.gvk)
return objList
}
// List implements [rest.Lister]
func (t *testStore[T, TList]) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
t.lock.Lock()
defer t.lock.Unlock()
items := []TestType{}
for _, obj := range t.items {
items = append(items, *obj)
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name > items[j].Name
})
list := &TestTypeList{
Items: items,
}
return list, nil
}
// ConvertToTable implements [rest.Lister]
func (t *testStore[T, TList]) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return ConvertToTableDefault[T](ctx, object, tableOptions, t.gvr.GroupResource())
}
// Watch implements [rest.Watcher]
func (t *testStore[T, TList]) Watch(ctx context.Context, internaloptions *metainternalversion.ListOptions) (watch.Interface, error) {
t.lock.Lock()
defer t.lock.Unlock()
w := &watcher{
ch: make(chan watch.Event, 100),
}
t.watcher = w
return w, nil
}
func (t *testStore[T, TList]) addEventLocked(event watch.Event) {
if t.watcher != nil {
t.watcher.addEvent(event)
}
}
// Delete implements [rest.GracefulDeleter]
func (t *testStore[T, TList]) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
t.lock.Lock()
defer t.lock.Unlock()
obj, found := t.items[name]
if !found {
return nil, false, apierrors.NewNotFound(t.gvr.GroupResource(), name)
}
if deleteValidation != nil {
err := deleteValidation(ctx, obj)
if err != nil {
return nil, false, err
}
}
delete(t.items, name)
t.addEventLocked(watch.Event{
Type: watch.Deleted,
Object: obj,
})
return obj, true, nil
}
type watcher struct {
closedLock sync.RWMutex
closed bool
ch chan watch.Event
}
// Stop implements [watch.Interface]
//
// As documented, Stop must only be called by the consumer (the k8s library) not the producer (our store)
func (w *watcher) Stop() {
w.closedLock.Lock()
defer w.closedLock.Unlock()
if !w.closed {
close(w.ch)
w.closed = true
}
}
// ResultChan implements [watch.Interface]
func (w *watcher) ResultChan() <-chan watch.Event {
return w.ch
}
func (w *watcher) addEvent(event watch.Event) bool {
w.closedLock.RLock()
defer w.closedLock.RUnlock()
if w.closed {
return false
}
w.ch <- event
return true
} }
// This was autogenerated. // This was autogenerated.
@@ -2929,18 +3143,3 @@ func schema_k8sio_apimachinery_pkg_version_Info(ref common.ReferenceCallback) co
}, },
} }
} }
// XXX: Implement DeleteCollection to simplify everything here
// var _ rest.StandardStorage = (*delegate[*TestType, typeChecker, *typeCheckerList, typeCheckerList])(nil)
var _ rest.Storage = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Scoper = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.KindProvider = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.GroupVersionKindProvider = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.SingularNameProvider = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Getter = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Lister = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.GracefulDeleter = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Creater = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Updater = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Watcher = (*delegate[*TestType, *TestTypeList])(nil)
var _ rest.Patcher = (*delegate[*TestType, *TestTypeList])(nil)

View File

@@ -1,66 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Package ext is a generated GoMock package.
package ext
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// MockUpdatedObjectInfo is a mock of UpdatedObjectInfo interface.
type MockUpdatedObjectInfo struct {
ctrl *gomock.Controller
recorder *MockUpdatedObjectInfoMockRecorder
isgomock struct{}
}
// MockUpdatedObjectInfoMockRecorder is the mock recorder for MockUpdatedObjectInfo.
type MockUpdatedObjectInfoMockRecorder struct {
mock *MockUpdatedObjectInfo
}
// NewMockUpdatedObjectInfo creates a new mock instance.
func NewMockUpdatedObjectInfo(ctrl *gomock.Controller) *MockUpdatedObjectInfo {
mock := &MockUpdatedObjectInfo{ctrl: ctrl}
mock.recorder = &MockUpdatedObjectInfoMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockUpdatedObjectInfo) EXPECT() *MockUpdatedObjectInfoMockRecorder {
return m.recorder
}
// Preconditions mocks base method.
func (m *MockUpdatedObjectInfo) Preconditions() *v1.Preconditions {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Preconditions")
ret0, _ := ret[0].(*v1.Preconditions)
return ret0
}
// Preconditions indicates an expected call of Preconditions.
func (mr *MockUpdatedObjectInfoMockRecorder) Preconditions() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Preconditions", reflect.TypeOf((*MockUpdatedObjectInfo)(nil).Preconditions))
}
// UpdatedObject mocks base method.
func (m *MockUpdatedObjectInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (runtime.Object, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdatedObject", ctx, oldObj)
ret0, _ := ret[0].(runtime.Object)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdatedObject indicates an expected call of UpdatedObject.
func (mr *MockUpdatedObjectInfoMockRecorder) UpdatedObject(ctx, oldObj any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedObject", reflect.TypeOf((*MockUpdatedObjectInfo)(nil).UpdatedObject), ctx, oldObj)
}

View File

@@ -1,93 +0,0 @@
package ext
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// Context wraps a context.Context and adds a few fields that will be useful for
// each requests handled by a Store.
//
// It will allow us to add more such fields without breaking Store implementation.
type Context struct {
context.Context
// User is the user making the request
User user.Info
// Authorizer helps you determines if a user is authorized to perform
// actions to specific resources.
Authorizer authorizer.Authorizer
// GroupVersionResource is the GVR of the request.
// It makes it easy to create errors such as in:
// apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
GroupVersionResource schema.GroupVersionResource
}
// Store should provide all required operations to serve a given resource. A
// resource is defined by the resource itself (T) and a list type for the resource (TList).
// For example, Store[*Token, *TokenList] is a store that allows CRUD operations on *Token
// objects and allows listing tokens in a *TokenList object.
//
// Store does not define the backing storage for a resource. The storage is
// up to the implementer. For example, resources could be stored in another ETCD
// database, in a SQLite database, in another built-in resource such as Secrets.
// It is also possible to have no storage at all.
//
// Errors returned by the Store should use errors from k8s.io/apimachinery/pkg/api/errors. This
// will ensure that the right error will be returned to the clients (eg: kubectl, client-go) so
// they can react accordingly. For example, if an object is not found, store should
// return the following error:
//
// apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name)
//
// Stores should make use of the various metav1.*Options as best as possible.
// Those options are the same options coming from client-go or kubectl, generally
// meant to control the behavior of the stores. Note: We currently don't have
// field-manager enabled.
type Store[T runtime.Object, TList runtime.Object] interface {
// Create should store the resource to some backing storage.
//
// It can apply modifications as necessary before storing it. It must
// return a resource of the type of the store, but can
// create/update/delete arbitrary objects in Kubernetes without
// returning them to the user.
//
// It is called either when a request creates a resource, or when a
// request updates a resource that doesn't exist.
Create(ctx Context, obj T, opts *metav1.CreateOptions) (T, error)
// Update should overwrite a resource that is present in the backing storage.
//
// It can apply modifications as necessary before storing it. It must
// return a resource of the type of the store, but can
// create/update/delete arbitrary objects in Kubernetes without
// returning them to the user.
//
// It is called when a request updates a resource (eg: through a patch or update request)
Update(ctx Context, obj T, opts *metav1.UpdateOptions) (T, error)
// Get retrieves the resource with the given name from the backing storage.
//
// Get is called for the following requests:
// - get requests: The object must be returned.
// - update requests: The object is needed to apply a JSON patch and to make some validation on the change.
// - delete requests: The object is needed to make some validation on it.
Get(ctx Context, name string, opts *metav1.GetOptions) (T, error)
// List retrieves all resources matching the given ListOptions from the backing storage.
List(ctx Context, opts *metav1.ListOptions) (TList, error)
// Watch sends change events to a returned channel.
//
// The store is responsible for closing the channel.
Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[T], error)
// Delete deletes the resource of the given name from the backing storage.
Delete(ctx Context, name string, opts *metav1.DeleteOptions) error
}
type WatchEvent[T runtime.Object] struct {
Event watch.EventType
Object T
}

View File

@@ -1,131 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./pkg/ext/store.go
//
// Generated by this command:
//
// mockgen -source=./pkg/ext/store.go -destination=./pkg/ext/store_mock.go -package=ext
//
// Package ext is a generated GoMock package.
package ext
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// MockStore is a mock of Store interface.
type MockStore[T runtime.Object, TList runtime.Object] struct {
ctrl *gomock.Controller
recorder *MockStoreMockRecorder[T, TList]
isgomock struct{}
}
// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder[T runtime.Object, TList runtime.Object] struct {
mock *MockStore[T, TList]
}
// NewMockStore creates a new mock instance.
func NewMockStore[T runtime.Object, TList runtime.Object](ctrl *gomock.Controller) *MockStore[T, TList] {
mock := &MockStore[T, TList]{ctrl: ctrl}
mock.recorder = &MockStoreMockRecorder[T, TList]{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore[T, TList]) EXPECT() *MockStoreMockRecorder[T, TList] {
return m.recorder
}
// Create mocks base method.
func (m *MockStore[T, TList]) Create(ctx Context, obj T, opts *v1.CreateOptions) (T, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", ctx, obj, opts)
ret0, _ := ret[0].(T)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Create indicates an expected call of Create.
func (mr *MockStoreMockRecorder[T, TList]) Create(ctx, obj, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockStore[T, TList])(nil).Create), ctx, obj, opts)
}
// Delete mocks base method.
func (m *MockStore[T, TList]) Delete(ctx Context, name string, opts *v1.DeleteOptions) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Delete", ctx, name, opts)
ret0, _ := ret[0].(error)
return ret0
}
// Delete indicates an expected call of Delete.
func (mr *MockStoreMockRecorder[T, TList]) Delete(ctx, name, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStore[T, TList])(nil).Delete), ctx, name, opts)
}
// Get mocks base method.
func (m *MockStore[T, TList]) Get(ctx Context, name string, opts *v1.GetOptions) (T, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, name, opts)
ret0, _ := ret[0].(T)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockStoreMockRecorder[T, TList]) Get(ctx, name, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore[T, TList])(nil).Get), ctx, name, opts)
}
// List mocks base method.
func (m *MockStore[T, TList]) List(ctx Context, opts *v1.ListOptions) (TList, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx, opts)
ret0, _ := ret[0].(TList)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockStoreMockRecorder[T, TList]) List(ctx, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockStore[T, TList])(nil).List), ctx, opts)
}
// Update mocks base method.
func (m *MockStore[T, TList]) Update(ctx Context, obj T, opts *v1.UpdateOptions) (T, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", ctx, obj, opts)
ret0, _ := ret[0].(T)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update.
func (mr *MockStoreMockRecorder[T, TList]) Update(ctx, obj, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockStore[T, TList])(nil).Update), ctx, obj, opts)
}
// Watch mocks base method.
func (m *MockStore[T, TList]) Watch(ctx Context, opts *v1.ListOptions) (<-chan WatchEvent[T], error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Watch", ctx, opts)
ret0, _ := ret[0].(<-chan WatchEvent[T])
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Watch indicates an expected call of Watch.
func (mr *MockStoreMockRecorder[T, TList]) Watch(ctx, opts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockStore[T, TList])(nil).Watch), ctx, opts)
}

199
pkg/ext/utils.go Normal file
View File

@@ -0,0 +1,199 @@
package ext
import (
"context"
"fmt"
"sync"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
)
// ConvertFunc will convert an object to a list of cell in a metav1.Table (think kubectl get table output)
type ConvertFunc[T runtime.Object] func(obj T) []string
// ConvertToTable helps implement [rest.Lister] and [rest.TableConvertor].
//
// It converts an object or a list of objects to a Table, which is used by kubectl
// (and Rancher UI) to display a table of the items.
func ConvertToTable[T runtime.Object](ctx context.Context, object runtime.Object, tableOptions runtime.Object, groupResource schema.GroupResource, columnDefs []metav1.TableColumnDefinition, convertFn ConvertFunc[T]) (*metav1.Table, error) {
result, err := convertToTable(ctx, object, tableOptions, groupResource, columnDefs, convertFn)
if err != nil {
return nil, convertError(err)
}
return result, nil
}
// ConvertToTableDefault helps implement [rest.Lister] and [rest.TableConvertor].
//
// This uses the default table conversion that displays the following two
// columns: Name and Created At.
func ConvertToTableDefault[T runtime.Object](ctx context.Context, object runtime.Object, tableOptions runtime.Object, groupResource schema.GroupResource) (*metav1.Table, error) {
return ConvertToTable[T](ctx, object, tableOptions, groupResource, nil, nil)
}
func convertToTable[T runtime.Object](ctx context.Context, object runtime.Object, tableOptions runtime.Object, groupResource schema.GroupResource, columnDefs []metav1.TableColumnDefinition, convertFn ConvertFunc[T]) (*metav1.Table, error) {
defaultTableConverter := rest.NewDefaultTableConvertor(groupResource)
table, err := defaultTableConverter.ConvertToTable(ctx, object, tableOptions)
if err != nil {
return nil, err
}
if columnDefs == nil {
return table, nil
}
// Override only if there were definitions before (to respect the NoHeader option)
if len(table.ColumnDefinitions) > 0 {
table.ColumnDefinitions = columnDefs
}
table.Rows = []metav1.TableRow{}
fn := func(obj runtime.Object) error {
objT, ok := obj.(T)
if !ok {
var zeroT T
return fmt.Errorf("expected %T but got %T", zeroT, obj)
}
cells := convertFn(objT)
if len(cells) != len(columnDefs) {
return fmt.Errorf("defined %d columns but got %d cells", len(columnDefs), len(cells))
}
table.Rows = append(table.Rows, metav1.TableRow{
Cells: cellStringToCellAny(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
}
}
return table, nil
}
func cellStringToCellAny(cells []string) []any {
var res []any
for _, cell := range cells {
res = append(res, cell)
}
return res
}
// CreateOrUpdate helps implement [rest.Updater] by handling most of the logic.
//
// It will call getFn to find the object. If not found, then createFn will
// be called, which should create the object. Otherwise, the updateFn will be called,
// which should update the object.
//
// createValidation is called before createFn. It will do validation such as:
// - verifying that the user is allowed to by checking for the "create" verb.
// See here for details: https://github.com/kubernetes/apiserver/blob/70ed6fdbea9eb37bd1d7558e90c20cfe888955e8/pkg/endpoints/handlers/update.go#L190-L201
// - running mutating/validating webhooks (though we're not using them yet)
//
// updateValidation is called before updateFn. It will do validation such as:
// - running mutating/validating webhooks (though we're not using them yet)
func CreateOrUpdate[T runtime.Object](
ctx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
forceAllowCreate bool,
options *metav1.UpdateOptions,
getFn func(ctx context.Context, name string, opts *metav1.GetOptions) (T, error),
createFn func(ctx context.Context, obj T, opts *metav1.CreateOptions) (T, error),
updateFn func(ctx context.Context, obj T, opts *metav1.UpdateOptions) (T, error),
) (runtime.Object, bool, error) {
oldObj, err := getFn(ctx, name, &metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
return nil, false, err
}
obj, err := objInfo.UpdatedObject(ctx, nil)
if err != nil {
return nil, false, convertError(err)
}
if err = createValidation(ctx, obj); err != nil {
return nil, false, convertError(err)
}
tObj, ok := obj.(T)
if !ok {
var zeroT T
return nil, false, convertError(fmt.Errorf("object was of type %T, not of expected type %T", obj, zeroT))
}
newObj, err := createFn(ctx, tObj, &metav1.CreateOptions{})
if err != nil {
return nil, false, convertError(err)
}
return newObj, true, nil
}
newObj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return nil, false, convertError(err)
}
newT, ok := newObj.(T)
if !ok {
var zeroT T
return nil, false, convertError(fmt.Errorf("object was of type %T, not of expected type %T", newObj, zeroT))
}
if updateValidation != nil {
err = updateValidation(ctx, newT, oldObj)
if err != nil {
return nil, false, convertError(err)
}
}
newT, err = updateFn(ctx, newT, options)
if err != nil {
return nil, false, err
}
return newT, false, nil
}
// ConvertListOptions converts an internal ListOptions to one used by client-go.
//
// This can be useful if wrapping Watch or List methods to client-go's equivalent.
func ConvertListOptions(options *metainternalversion.ListOptions) (*metav1.ListOptions, error) {
scheme := sync.OnceValue(func() *runtime.Scheme {
scheme := runtime.NewScheme()
metainternalversion.AddToScheme(scheme)
return scheme
})()
var out metav1.ListOptions
err := scheme.Convert(options, &out, nil)
if err != nil {
return nil, fmt.Errorf("converting list options: %w", err)
}
return &out, nil
}
func convertError(err error) error {
if _, ok := err.(apierrors.APIStatus); ok {
return err
}
return apierrors.NewInternalError(err)
}

69
pkg/ext/utils_test.go Normal file
View File

@@ -0,0 +1,69 @@
package ext
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestConvertListOptions(t *testing.T) {
internal := &metainternalversion.ListOptions{
ResourceVersion: "foo",
Watch: true,
}
expected := &metav1.ListOptions{
ResourceVersion: "foo",
Watch: true,
}
got, err := ConvertListOptions(internal)
assert.NoError(t, err)
assert.Equal(t, expected, got)
}
func TestConvertError(t *testing.T) {
tests := []struct {
name string
input error
output error
}{
{
name: "api status error",
input: &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusNotFound,
Reason: metav1.StatusReasonNotFound,
},
},
output: &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusNotFound,
Reason: metav1.StatusReasonNotFound,
},
},
},
{
name: "generic error",
input: assert.AnError,
output: &apierrors.StatusError{ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusInternalServerError,
Reason: metav1.StatusReasonInternalError,
Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{{Message: assert.AnError.Error()}},
},
Message: fmt.Sprintf("Internal error occurred: %v", assert.AnError),
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.output, convertError(tt.input))
})
}
}