diff --git a/pkg/ext/apiserver.go b/pkg/ext/apiserver.go index 695372b0..935cc06b 100644 --- a/pkg/ext/apiserver.go +++ b/pkg/ext/apiserver.go @@ -8,7 +8,6 @@ import ( "strings" "sync" - "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" @@ -72,19 +71,19 @@ type ExtensionAPIServerOptions struct { // // 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/// as // 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 // can expect the following requests from the main kube-apiserver: // -// -// /openapi/v2 system:aggregator [system:authenticated] -// /openapi/v3 system:aggregator [system:authenticated] -// /apis system:kube-aggregator [system:masters system:authenticated] -// /apis/ext.cattle.io/v1 system:kube-aggregator [system:masters system:authenticated] +// +// /openapi/v2 system:aggregator [system:authenticated] +// /openapi/v3 system:aggregator [system:authenticated] +// /apis system:kube-aggregator [system:masters system:authenticated] +// /apis/ext.cattle.io/v1 system:kube-aggregator [system:masters system:authenticated] type ExtensionAPIServer struct { codecs serializer.CodecFactory scheme *runtime.Scheme @@ -201,34 +200,49 @@ func (s *ExtensionAPIServer) ServeHTTP(w http.ResponseWriter, req *http.Request) 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 { + return s.authorizer +} + +// Install adds a new store to the extension API server. // -// Here's an example store for a Token and TokenList resource in the ext.cattle.io/v1 apiVersion: +// 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. // -// gvk := schema.GroupVersionKind{ -// Group: "ext.cattle.io", -// Version: "v1", -// Kind: "Token", -// } -// InstallStore(s, &Token{}, &TokenList{}, "tokens", "token", gvk, store) +// A store MUST implement the following interfaces: [rest.Storage], [rest.Scoper], [rest.GroupVersionKindProvider] +// and [rest.SingularNameProvider]. // -// 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) - } - +// 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] if !ok { 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) } - del := &delegateError[T, TList]{ - 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 + apiGroup.VersionedResourcesStorageMap[gvk.Version][resourceName] = storage s.apiGroups[gvk.Group] = apiGroup return nil } diff --git a/pkg/ext/apiserver_authentication_test.go b/pkg/ext/apiserver_authentication_test.go index f6649fa3..a910f7fe 100644 --- a/pkg/ext/apiserver_authentication_test.go +++ b/pkg/ext/apiserver_authentication_test.go @@ -1,6 +1,7 @@ package ext import ( + "context" "encoding/json" "fmt" "io" @@ -12,22 +13,32 @@ import ( "github.com/stretchr/testify/require" 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/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/options" ) +var _ rest.Storage = (*authnTestStore)(nil) +var _ rest.Lister = (*authnTestStore)(nil) + type authnTestStore struct { - *testStore + *testStore[*TestType, *TestTypeList] userCh chan user.Info } -func (t *authnTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { - t.userCh <- ctx.User +func (t *authnTestStore) List(ctx context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) { + userInfo, ok := request.UserFrom(ctx) + if !ok { + return nil, convertError(fmt.Errorf("missing user info")) + } + + t.userCh <- userInfo return &testTypeListFixture, nil } @@ -50,10 +61,10 @@ func TestAuthenticationCustom(t *testing.T) { require.NoError(t, err) store := &authnTestStore{ - testStore: &testStore{}, + testStore: newDefaultTestStore(), 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.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) { diff --git a/pkg/ext/apiserver_authorization_test.go b/pkg/ext/apiserver_authorization_test.go index dbcda54d..a6b45bc2 100644 --- a/pkg/ext/apiserver_authorization_test.go +++ b/pkg/ext/apiserver_authorization_test.go @@ -23,32 +23,40 @@ import ( "go.uber.org/mock/gomock" rbacv1 "k8s.io/api/rbac/v1" 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/serializer" yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apimachinery/pkg/watch" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/options" ) type authzTestStore struct { - *testStore + *testStore[*TestType, *TestTypeList] + authorizer authorizer.Authorizer } -func (t *authzTestStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) { - if name == "not-found" { - return nil, apierrors.NewNotFound(ctx.GroupVersionResource.GroupResource(), name) - } - return t.testStore.Get(ctx, name, opts) +// Get implements [rest.Getter] +func (t *authzTestStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + return t.get(ctx, name, options) } -func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { - if ctx.User.GetName() == "read-only-error" { - decision, _, err := ctx.Authorizer.Authorize(ctx, authorizer.AttributesRecord{ - User: ctx.User, +// List implements [rest.Lister] +func (t *authzTestStore) List(ctx context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) { + userInfo, ok := request.UserFrom(ctx) + 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", Resource: "testtypes", ResourceRequest: true, @@ -58,7 +66,7 @@ func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeL if err == nil { 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.APIVersion = "v1" return nil, forbidden @@ -67,6 +75,54 @@ func (t *authzTestStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeL 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() { t := s.T() @@ -89,10 +145,7 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() { ln, _, err := options.CreateListener("", ":0", net.ListenConfig{}) require.NoError(t, err) - store := &authzTestStore{ - testStore: &testStore{}, - } - extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { + extensionAPIServer, cleanup, err := setupExtensionAPIServerNoStore(t, scheme, func(opts *ExtensionAPIServerOptions) { opts.Listener = ln opts.Authorizer = authz opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) { @@ -104,7 +157,17 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() { User: user, }, 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) defer cleanup() diff --git a/pkg/ext/apiserver_test.go b/pkg/ext/apiserver_test.go index 42717de5..d3dc824c 100644 --- a/pkg/ext/apiserver_test.go +++ b/pkg/ext/apiserver_test.go @@ -11,11 +11,11 @@ import ( "net/http/httptest" "sort" "strings" + "sync" "testing" "time" "github.com/stretchr/testify/require" - apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -24,6 +24,7 @@ import ( "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" + regrest "k8s.io/apiserver/pkg/registry/rest" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" ) @@ -41,83 +42,6 @@ func authzAllowAll(ctx context.Context, a authorizer.Attributes) (authorizer.Dec 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) { scheme := runtime.NewScheme() AddToScheme(scheme) @@ -128,8 +52,10 @@ func TestStore(t *testing.T) { ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0") require.NoError(t, err) - store := newMapStore() - extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { + store := newDefaultTestStore() + store.items = make(map[string]*TestType) + + extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) { opts.Listener = ln opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authenticator = authenticator.RequestFunc(authAsAdmin) @@ -298,34 +224,46 @@ func TestStore(t *testing.T) { } } -var _ Store[*TestTypeOther, *TestTypeOtherList] = (*testStoreOther)(nil) - -// This store is meant to be able to test many stores -type testStoreOther struct { +// This store tests when there's only a subset of verbs supported +type partialStorage struct { + gvk schema.GroupVersionKind } -func (t *testStoreOther) Create(ctx Context, obj *TestTypeOther, opts *metav1.CreateOptions) (*TestTypeOther, error) { - return &testTypeOtherFixture, nil +// New implements [regrest.Storage] +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) { - return &testTypeOtherFixture, nil +// Destroy implements [regrest.Storage] +func (t *partialStorage) Destroy() { } -func (t *testStoreOther) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestTypeOther, error) { - return &testTypeOtherFixture, nil +// GetSingularName implements [regrest.SingularNameProvider] +func (t *partialStorage) GetSingularName() string { + return "testtype" } -func (t *testStoreOther) List(ctx Context, opts *metav1.ListOptions) (*TestTypeOtherList, error) { - return &testTypeOtherListFixture, nil +// NamespaceScoped implements [regrest.Scoper] +func (t *partialStorage) NamespaceScoped() bool { + return false } -func (t *testStoreOther) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestTypeOther], error) { - return nil, nil +// GroupVersionKind implements [regrest.GroupVersionKindProvider] +func (t *partialStorage) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind { + return t.gvk } -func (t *testStoreOther) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error { - return nil +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 + } + } + + return nil, nil } // The POC had a bug where multiple resources couldn't be installed so we're @@ -343,35 +281,67 @@ func TestDiscoveryAndOpenAPI(t *testing.T) { Group: "ext2.cattle.io", Version: "v3", } + + partialGroupVersion := schema.GroupVersion{ + Group: "ext.cattle.io", + Version: "v4", + } scheme.AddKnownTypes(differentVersion, &TestType{}, &TestTypeList{}) scheme.AddKnownTypes(differentGroupVersion, &TestType{}, &TestTypeList{}) + scheme.AddKnownTypes(partialGroupVersion, &TestType{}, &TestTypeList{}) metav1.AddToGroupVersion(scheme, differentVersion) metav1.AddToGroupVersion(scheme, differentGroupVersion) + metav1.AddToGroupVersion(scheme, partialGroupVersion) ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0") require.NoError(t, err) - store := &testStore{} - extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, &TestType{}, &TestTypeList{}, store, func(opts *ExtensionAPIServerOptions) { + store := newDefaultTestStore() + extensionAPIServer, cleanup, err := setupExtensionAPIServer(t, scheme, store, func(opts *ExtensionAPIServerOptions) { opts.Listener = ln opts.Authorizer = authorizer.AuthorizerFunc(authzAllowAll) opts.Authenticator = authenticator.RequestFunc(authAsAdmin) }, func(s *ExtensionAPIServer) error { - store := &testStoreOther{} - err := InstallStore(s, &TestTypeOther{}, &TestTypeOtherList{}, "testtypeothers", "testtypeother", testTypeGV.WithKind("TestTypeOther"), store) + err = s.Install("testtypeothers", testTypeGV.WithKind("TestTypeOther"), &testStore[*TestTypeOther, *TestTypeOtherList]{ + singular: "testtypeother", + objT: &TestTypeOther{}, + objListT: &TestTypeOtherList{}, + gvk: testTypeGV.WithKind("TestTypeOther"), + gvr: schema.GroupVersionResource{Group: testTypeGV.Group, Version: testTypeGV.Version, Resource: "testtypes"}, + }) + if err != nil { + return err + } + + 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 { return err } - err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentVersion.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 { return err } - err = InstallStore(s, &TestType{}, &TestTypeList{}, "testtypes", "testtype", differentGroupVersion.WithKind("TestType"), &testStore{}) + err = s.Install("testtypes", partialGroupVersion.WithKind("TestType"), &partialStorage{ + gvk: partialGroupVersion.WithKind("TestType"), + }) if err != nil { return err } + return nil }) require.NoError(t, err) @@ -401,6 +371,10 @@ func TestDiscoveryAndOpenAPI(t *testing.T) { { Name: "ext.cattle.io", Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "ext.cattle.io/v4", + Version: "v4", + }, { GroupVersion: "ext.cattle.io/v2", Version: "v2", @@ -450,6 +424,10 @@ func TestDiscoveryAndOpenAPI(t *testing.T) { GroupVersion: "ext.cattle.io/v2", Version: "v2", }, + { + GroupVersion: "ext.cattle.io/v4", + Version: "v4", + }, { GroupVersion: "ext.cattle.io/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", expectedStatusCode: http.StatusOK, @@ -664,15 +668,29 @@ func TestNoStore(t *testing.T) { require.NoError(t, err) } -func setupExtensionAPIServer[ - T runtime.Object, - TList runtime.Object, -]( +func setupExtensionAPIServer( + t *testing.T, + 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, scheme *runtime.Scheme, - objT T, - objTList TList, - store Store[T, TList], optionSetter func(*ExtensionAPIServerOptions), extensionAPIServerSetter func(*ExtensionAPIServer) error, ) (*ExtensionAPIServer, func(), error) { @@ -694,11 +712,6 @@ func setupExtensionAPIServer[ 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 { err = extensionAPIServerSetter(extensionAPIServer) if err != nil { @@ -768,3 +781,222 @@ func createRecordingWatcher(scheme *runtime.Scheme, gvr schema.GroupVersionResou stop: myWatch.Stop, }, 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) + } + } + }) + } + +} diff --git a/pkg/ext/delegate.go b/pkg/ext/delegate.go deleted file mode 100644 index ff40bb1c..00000000 --- a/pkg/ext/delegate.go +++ /dev/null @@ -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 -} diff --git a/pkg/ext/delegate_error.go b/pkg/ext/delegate_error.go deleted file mode 100644 index 1c4b2a97..00000000 --- a/pkg/ext/delegate_error.go +++ /dev/null @@ -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) -} diff --git a/pkg/ext/delegate_error_test.go b/pkg/ext/delegate_error_test.go deleted file mode 100644 index 234b2d59..00000000 --- a/pkg/ext/delegate_error_test.go +++ /dev/null @@ -1,1097 +0,0 @@ -package ext - -import ( - "context" - "errors" - "fmt" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - gomock "go.uber.org/mock/gomock" - 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/conversion" - "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/endpoints/request" - "k8s.io/apiserver/pkg/registry/rest" -) - -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) { - output := convertError(tt.input) - assert.Equal(t, tt.output, output) - }) - } - -} - -func TestDelegateError_Watch(t *testing.T) { - type input struct { - ctx context.Context - internaloptions *metainternalversion.ListOptions - } - - type output struct { - watch watch.Interface - err error - } - - type testCase struct { - name string - input input - expected output - storeSetup func(*MockStore[*TestType, *TestTypeList]) - simulateConversionError bool - wantedErr bool - } - - tests := []testCase{ - { - name: "missing user in context", - input: input{ - ctx: context.TODO(), - internaloptions: &metainternalversion.ListOptions{}, - }, - wantedErr: true, - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - }, - { - name: "convert list error", - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - internaloptions: &metainternalversion.ListOptions{}, - }, - simulateConversionError: true, - wantedErr: true, - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - }, - } - - for _, tt := range tests { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - if !tt.simulateConversionError { - scheme.AddConversionFunc(&metainternalversion.ListOptions{}, &metav1.ListOptions{}, convert_internalversion_ListOptions_to_v1_ListOptions) - } - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - watch, err := testDelegate.Watch(tt.input.ctx, tt.input.internaloptions) - if tt.wantedErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an api error - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, watch, tt.expected.watch) - } - } -} - -func TestDelegateError_Update(t *testing.T) { - type input struct { - parentCtx context.Context - name string - objInfo rest.UpdatedObjectInfo - createValidation rest.ValidateObjectFunc - updateValidation rest.ValidateObjectUpdateFunc - forceAllowCreate bool - options *metav1.UpdateOptions - } - - type output struct { - obj runtime.Object - created bool - err error - } - - type testCase struct { - name string - setup func(*MockUpdatedObjectInfo, *MockStore[*TestType, *TestTypeList]) - input input - expect output - wantErr bool - } - - tests := []testCase{ - { - name: "working case", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - // objInfo is created in the for loop - forceAllowCreate: false, - options: &metav1.UpdateOptions{}, - }, - expect: output{ - obj: &TestType{}, - created: false, - err: nil, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - wantErr: false, - }, - { - name: "missing user in context", - input: input{ - parentCtx: context.TODO(), - }, - setup: func(muoi *MockUpdatedObjectInfo, ms *MockStore[*TestType, *TestTypeList]) {}, - expect: output{ - obj: nil, - created: false, - err: errMissingUserInfo, - }, - wantErr: true, - }, - { - name: "get failed - other error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - wantErr: true, - }, - { - name: "get failed - not found - updated object error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, - &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotFound, - Reason: metav1.StatusReasonNotFound, - }, - }, - ) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - wantErr: true, - }, - { - name: "get failed - not found - create succeeded", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { return nil }, - }, - expect: output{ - obj: &TestType{}, - created: true, - err: nil, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, - &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotFound, - Reason: metav1.StatusReasonNotFound, - }, - }, - ) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - store.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - wantErr: false, - }, - { - name: "get failed - not found - create validation error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return assert.AnError - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, - &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotFound, - Reason: metav1.StatusReasonNotFound, - }, - }, - ) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - wantErr: true, - }, - { - name: "get failed - not found - type error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, - &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotFound, - Reason: metav1.StatusReasonNotFound, - }, - }, - ) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&runtime.Unknown{}, nil) - - }, - wantErr: true, - }, - { - name: "get failed - not found - store create error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, - &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotFound, - Reason: metav1.StatusReasonNotFound, - }, - }, - ) - store.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - - }, - wantErr: true, - }, - { - name: "get worked - updated object error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - - }, - wantErr: true, - }, - { - name: "get worked - type error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&runtime.Unknown{}, nil) - - }, - wantErr: true, - }, - { - name: "get worked - update validation error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - updateValidation: func(ctx context.Context, obj, old runtime.Object) error { - return assert.AnError - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - - }, - wantErr: true, - }, - { - name: "get worked - store update error", - input: input{ - parentCtx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-user", - createValidation: func(ctx context.Context, obj runtime.Object) error { - return nil - }, - }, - expect: output{ - obj: nil, - created: false, - err: assert.AnError, - }, - setup: func(objInfo *MockUpdatedObjectInfo, store *MockStore[*TestType, *TestTypeList]) { - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - objInfo.EXPECT().UpdatedObject(gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - mockObjInfo := NewMockUpdatedObjectInfo(ctrl) - tt.input.objInfo = mockObjInfo - tt.setup(mockObjInfo, mockStore) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - obj, created, err := testDelegate.Update(tt.input.parentCtx, tt.input.name, tt.input.objInfo, tt.input.createValidation, tt.input.updateValidation, tt.input.forceAllowCreate, tt.input.options) - - if tt.wantErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an apierror - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expect.obj, obj) - assert.Equal(t, tt.expect.created, created) - } - }) - } - -} - -func TestDelegateError_Create(t *testing.T) { - type input struct { - ctx context.Context - obj runtime.Object - createValidation rest.ValidateObjectFunc - options *metav1.CreateOptions - } - - type output struct { - createResult runtime.Object - err error - } - - type testCase struct { - name string - storeSetup func(*MockStore[*TestType, *TestTypeList]) - wantErr bool - input input - expect output - } - - tests := []testCase{ - { - name: "working case", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - obj: &TestType{}, - options: &metav1.CreateOptions{}, - }, - expect: output{ - createResult: &TestType{}, - err: nil, - }, - wantErr: false, - }, - { - name: "missing user in the context", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - input: input{ - ctx: context.Background(), - obj: &TestType{}, - options: &metav1.CreateOptions{}, - }, - expect: output{ - createResult: nil, - err: errMissingUserInfo, - }, - wantErr: true, - }, - { - name: "store create error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Create(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - obj: &TestType{}, - options: &metav1.CreateOptions{}, - }, - expect: output{ - createResult: nil, - err: assert.AnError, - }, - wantErr: true, - }, - { - name: "wrong type error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - obj: &runtime.Unknown{}, - options: &metav1.CreateOptions{}, - }, - expect: output{ - createResult: nil, - err: assert.AnError, - }, - wantErr: true, - }, - { - name: "create validation error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - obj: &TestType{}, - createValidation: func(ctx context.Context, obj runtime.Object) error { - return assert.AnError - }, - options: &metav1.CreateOptions{}, - }, - expect: output{ - createResult: &TestType{}, - err: assert.AnError, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - tt.storeSetup(mockStore) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - result, err := testDelegate.Create(tt.input.ctx, tt.input.obj, tt.input.createValidation, tt.input.options) - if tt.wantErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an apierror - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expect.createResult, result) - } - }) - } -} - -func TestDelegateError_Delete(t *testing.T) { - type input struct { - ctx context.Context - name string - deleteValidation rest.ValidateObjectFunc - options *metav1.DeleteOptions - } - - type output struct { - deleteResult runtime.Object - completed bool - err error - } - - type testCase struct { - name string - storeSetup func(*MockStore[*TestType, *TestTypeList]) - wantErr bool - input input - expect output - } - - tests := []testCase{ - { - name: "working case", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - ms.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - options: &metav1.DeleteOptions{}, - }, - expect: output{ - deleteResult: &TestType{}, - completed: true, - err: nil, - }, - wantErr: false, - }, - { - name: "missing user in the context", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - input: input{ - ctx: context.Background(), - name: "test-object", - options: &metav1.DeleteOptions{}, - }, - expect: output{ - deleteResult: nil, - completed: false, - err: errMissingUserInfo, - }, - wantErr: true, - }, - { - name: "store get error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - options: &metav1.DeleteOptions{}, - }, - expect: output{ - deleteResult: nil, - completed: false, - err: assert.AnError, - }, - wantErr: true, - }, - { - name: "store delete error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - ms.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(assert.AnError) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - options: &metav1.DeleteOptions{}, - }, - expect: output{ - deleteResult: &TestType{}, - completed: true, - err: assert.AnError, - }, - wantErr: true, - }, - { - name: "delete validation error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - deleteValidation: func(ctx context.Context, obj runtime.Object) error { return assert.AnError }, - options: &metav1.DeleteOptions{}, - }, - expect: output{ - deleteResult: nil, - completed: false, - err: assert.AnError, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - tt.storeSetup(mockStore) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - result, completed, err := testDelegate.Delete(tt.input.ctx, tt.input.name, tt.input.deleteValidation, tt.input.options) - if tt.wantErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an apierror - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expect.deleteResult, result) - assert.Equal(t, tt.expect.completed, completed) - } - }) - } -} - -func TestDelegateError_Get(t *testing.T) { - type input struct { - ctx context.Context - name string - options *metav1.GetOptions - } - - type output struct { - getResult runtime.Object - err error - } - - type testCase struct { - name string - storeSetup func(*MockStore[*TestType, *TestTypeList]) - wantErr bool - input input - expect output - } - - tests := []testCase{ - { - name: "working case", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&TestType{}, nil) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - options: &metav1.GetOptions{}, - }, - expect: output{ - getResult: &TestType{}, - err: nil, - }, - wantErr: false, - }, - { - name: "missing user in the context", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) {}, - input: input{ - ctx: context.Background(), - name: "testing-obj", - options: &metav1.GetOptions{}, - }, - expect: output{ - getResult: nil, - err: errMissingUserInfo, - }, - wantErr: true, - }, - { - name: "store get error", - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - name: "test-object", - options: &metav1.GetOptions{}, - }, - expect: output{ - getResult: &TestType{}, - err: assert.AnError, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - tt.storeSetup(mockStore) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - result, err := testDelegate.Get(tt.input.ctx, tt.input.name, tt.input.options) - if tt.wantErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an apierror - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expect.getResult, result) - } - - }) - } -} - -func TestDelegateError_List(t *testing.T) { - type input struct { - ctx context.Context - listOptions *metainternalversion.ListOptions - } - - type output struct { - listResult runtime.Object - err error - } - - type testCase struct { - name string - storeSetup func(*MockStore[*TestType, *TestTypeList]) - simulateConvertError bool - wantErr bool - input input - expect output - } - - tests := []testCase{ - { - name: "working case, for completion reasons", - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - listOptions: &metainternalversion.ListOptions{}, - }, - storeSetup: func(ms *MockStore[*TestType, *TestTypeList]) { - ms.EXPECT().List(gomock.Any(), gomock.Any()).Return(&TestTypeList{}, nil) - }, - wantErr: false, - expect: output{ - listResult: &TestTypeList{}, - err: nil, - }, - }, - { - name: "missing user in the context", - input: input{ - ctx: context.Background(), - listOptions: &metainternalversion.ListOptions{}, - }, - storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) {}, - wantErr: true, - expect: output{ - listResult: nil, - err: errMissingUserInfo, - }, - }, - { - name: "convertListOptions error", - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - listOptions: &metainternalversion.ListOptions{}, - }, - storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) {}, - simulateConvertError: true, - wantErr: true, - expect: output{ - listResult: nil, - err: assert.AnError, - }, - }, - { - name: "error returned by store", - input: input{ - ctx: request.WithUser(context.Background(), &user.DefaultInfo{ - Name: "test-user", - }), - listOptions: &metainternalversion.ListOptions{}, - }, - storeSetup: func(mockStore *MockStore[*TestType, *TestTypeList]) { - mockStore.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) - }, - wantErr: true, - expect: output{ - listResult: nil, - err: assert.AnError, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scheme := runtime.NewScheme() - addToSchemeTest(scheme) - - if !tt.simulateConvertError { - scheme.AddConversionFunc(&metainternalversion.ListOptions{}, &metav1.ListOptions{}, convert_internalversion_ListOptions_to_v1_ListOptions) - } - - gvk := schema.GroupVersionKind{Group: "ext.cattle.io", Version: "v1", Kind: "TestType"} - gvr := schema.GroupVersionResource{Group: "ext.cattle.io", Version: "v1", Resource: "testtypes"} - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockStore := NewMockStore[*TestType, *TestTypeList](ctrl) - tt.storeSetup(mockStore) - - testDelegate := &delegateError[*TestType, *TestTypeList]{ - inner: &delegate[*TestType, *TestTypeList]{ - scheme: scheme, - t: &TestType{}, - tList: &TestTypeList{}, - gvk: gvk, - gvr: gvr, - store: mockStore, - }, - } - - result, err := testDelegate.List(tt.input.ctx, tt.input.listOptions) - if tt.wantErr { - // check if we have an error - assert.Error(t, err) - - // check if error is an apierror - _, ok := err.(apierrors.APIStatus) - assert.True(t, ok) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expect.listResult, result) - } - }) - } -} - -func convert_internalversion_ListOptions_to_v1_ListOptions(in, out interface{}, s conversion.Scope) error { - i, ok := in.(*metainternalversion.ListOptions) - if !ok { - return errors.New("cannot convert in param into internalversion.ListOptions") - } - o, ok := out.(*metav1.ListOptions) - if !ok { - return errors.New("cannot convert out param into metav1.ListOptions") - } - if i.LabelSelector != nil { - o.LabelSelector = i.LabelSelector.String() - } - if i.FieldSelector != nil { - o.FieldSelector = i.FieldSelector.String() - } - o.Watch = i.Watch - o.ResourceVersion = i.ResourceVersion - o.TimeoutSeconds = i.TimeoutSeconds - return nil -} diff --git a/pkg/ext/fixtures_test.go b/pkg/ext/fixtures_test.go index 263b3aa9..fe8350c7 100644 --- a/pkg/ext/fixtures_test.go +++ b/pkg/ext/fixtures_test.go @@ -1,9 +1,17 @@ package ext 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" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" "k8s.io/apiserver/pkg/registry/rest" common "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" @@ -162,33 +170,239 @@ func (t *TestTypeOther) DeepCopyObject() runtime.Object { 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[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 +} -type testStore struct { +func newDefaultTestStore() *testStore[*TestType, *TestTypeList] { + 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) Create(ctx Context, obj *TestType, opts *metav1.CreateOptions) (*TestType, error) { - return &testTypeFixture, nil +// New implements [rest.Storage] +func (t *testStore[T, TList]) New() runtime.Object { + obj := t.objT.DeepCopyObject() + obj.GetObjectKind().SetGroupVersionKind(t.gvk) + return obj } -func (t *testStore) Update(ctx Context, obj *TestType, opts *metav1.UpdateOptions) (*TestType, error) { - return &testTypeFixture, nil +// GetSingularName implements [rest.SingularNameProvider] +func (t *testStore[T, TList]) GetSingularName() string { + return t.singular } -func (t *testStore) Get(ctx Context, name string, opts *metav1.GetOptions) (*TestType, error) { - return &testTypeFixture, nil +// NamespaceScoped implements [rest.Scoper] +func (t *testStore[T, TList]) NamespaceScoped() bool { + return false } -func (t *testStore) List(ctx Context, opts *metav1.ListOptions) (*TestTypeList, error) { - return &testTypeListFixture, nil +// GroupVersionKind implements [rest.GroupVersionKindProvider] +func (t *testStore[T, TList]) GroupVersionKind(_ schema.GroupVersion) schema.GroupVersionKind { + return t.gvk } -func (t *testStore) Watch(ctx Context, opts *metav1.ListOptions) (<-chan WatchEvent[*TestType], error) { - return nil, nil +// Destroy implements [rest.Storage] +func (t *testStore[T, TList]) Destroy() { } -func (t *testStore) Delete(ctx Context, name string, opts *metav1.DeleteOptions) error { - return nil +// 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. @@ -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) diff --git a/pkg/ext/rest_mock.go b/pkg/ext/rest_mock.go deleted file mode 100644 index 40b13fce..00000000 --- a/pkg/ext/rest_mock.go +++ /dev/null @@ -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) -} diff --git a/pkg/ext/store.go b/pkg/ext/store.go deleted file mode 100644 index ecc3a4d2..00000000 --- a/pkg/ext/store.go +++ /dev/null @@ -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 -} diff --git a/pkg/ext/store_mock.go b/pkg/ext/store_mock.go deleted file mode 100644 index 159d98ed..00000000 --- a/pkg/ext/store_mock.go +++ /dev/null @@ -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) -} diff --git a/pkg/ext/utils.go b/pkg/ext/utils.go new file mode 100644 index 00000000..e95563cc --- /dev/null +++ b/pkg/ext/utils.go @@ -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) +} diff --git a/pkg/ext/utils_test.go b/pkg/ext/utils_test.go new file mode 100644 index 00000000..9705b33e --- /dev/null +++ b/pkg/ext/utils_test.go @@ -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)) + }) + } +}